feat(cliproxy): add variant port isolation for concurrent proxy instances

Enables running multiple CLIProxy variants simultaneously on different
ports. Each variant now gets a unique port in the 18100-18199 range,
allowing concurrent use of providers like Gemini + Codex + custom variants.

Key changes:
- Add port field to variant config schema
- Implement automatic port allocation (18100 + variant index)
- Support variant-specific settings paths in config generator
- Display port in dashboard UI for debugging
- Show all models in variant editor dropdown
- Add comprehensive tests for port allocation edge cases

Closes related variant isolation work.
This commit is contained in:
kaitranntt
2025-12-24 04:15:04 -05:00
parent 9d8268b0f6
commit 0bcaf4bc68
23 changed files with 1999 additions and 244 deletions
+5 -3
View File
@@ -625,12 +625,14 @@ export async function execClaudeWithCLIProxy(
// 8. Cleanup: unregister session when Claude exits (local mode only)
// Proxy persists by default - use 'ccs cliproxy stop' to kill manually
// Capture port for cleanup (avoids closure issues)
const sessionPort = cfg.port;
claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`);
// Unregister this session (proxy keeps running for persistence) - only for local mode
if (sessionId) {
unregisterSession(sessionId);
unregisterSession(sessionId, sessionPort);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
}
@@ -646,7 +648,7 @@ export async function execClaudeWithCLIProxy(
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId);
unregisterSession(sessionId, sessionPort);
}
process.exit(1);
});
@@ -657,7 +659,7 @@ export async function execClaudeWithCLIProxy(
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId);
unregisterSession(sessionId, sessionPort);
}
claude.kill('SIGTERM');
};
+28 -10
View File
@@ -116,10 +116,21 @@ export function getAuthDir(): string {
}
/**
* Get config file path
* Get config file path for a specific port.
* Default port uses config.yaml, others use config-{port}.yaml.
*/
export function getConfigPathForPort(port: number): string {
if (port === CLIPROXY_DEFAULT_PORT) {
return path.join(getCliproxyDir(), 'config.yaml');
}
return path.join(getCliproxyDir(), `config-${port}.yaml`);
}
/**
* Get config file path (default port)
*/
export function getConfigPath(): string {
return path.join(getCliproxyDir(), 'config.yaml');
return getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
}
/**
@@ -238,7 +249,7 @@ export function generateConfig(
provider: CLIProxyProvider,
port: number = CLIPROXY_DEFAULT_PORT
): string {
const configPath = getConfigPath();
const configPath = getConfigPathForPort(port);
// Ensure provider auth directory exists
const authDir = getProviderAuthDir(provider);
@@ -260,7 +271,7 @@ export function generateConfig(
* @returns Path to new config file
*/
export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
const configPath = getConfigPath();
const configPath = getConfigPathForPort(port);
// Read existing port if config exists (preserve user's port choice)
let effectivePort = port;
@@ -316,22 +327,29 @@ export function configNeedsRegeneration(): boolean {
}
/**
* Check if config exists for provider
* Check if config exists for port
*/
export function configExists(): boolean {
return fs.existsSync(getConfigPath());
export function configExists(port: number = CLIPROXY_DEFAULT_PORT): boolean {
return fs.existsSync(getConfigPathForPort(port));
}
/**
* Delete config file
* Delete config file for specific port
*/
export function deleteConfig(): void {
const configPath = getConfigPath();
export function deleteConfigForPort(port: number): void {
const configPath = getConfigPathForPort(port);
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
}
}
/**
* Delete config file (default port)
*/
export function deleteConfig(): void {
deleteConfigForPort(CLIPROXY_DEFAULT_PORT);
}
/**
* Get path to user settings file for provider
* Example: ~/.ccs/gemini.settings.json
@@ -12,6 +12,13 @@ import {
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
/** First port for variant profiles (8318 = default + 1) */
export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1;
/** Maximum port offset for variants (100 ports: 8318-8417) */
export const VARIANT_PORT_MAX_OFFSET = 100;
/** Variant configuration structure */
export interface VariantConfig {
@@ -19,6 +26,7 @@ export interface VariantConfig {
settings?: string;
account?: string;
model?: string;
port?: number;
}
/**
@@ -37,6 +45,34 @@ export function variantExistsInConfig(name: string): boolean {
}
}
/**
* Get next available port for a new variant.
* Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE.
*/
export function getNextAvailablePort(): number {
const variants = listVariantsFromConfig();
const usedPorts = new Set<number>();
for (const name of Object.keys(variants)) {
const port = variants[name].port;
if (port) usedPorts.add(port);
}
// Find first available port in range
for (let offset = 0; offset < VARIANT_PORT_MAX_OFFSET; offset++) {
const port = VARIANT_PORT_BASE + offset;
if (!usedPorts.has(port)) {
return port;
}
}
const variantCount = Object.keys(variants).length;
throw new Error(
`Port limit reached (${variantCount}/${VARIANT_PORT_MAX_OFFSET} variants). ` +
`Delete unused variants with 'ccs cliproxy remove <name>' to free ports.`
);
}
/**
* List variants from config
*/
@@ -48,7 +84,12 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
const result: Record<string, VariantConfig> = {};
for (const name of Object.keys(variants)) {
const v = variants[name];
result[name] = { provider: v.provider, settings: v.settings, account: v.account };
result[name] = {
provider: v.provider,
settings: v.settings,
account: v.account,
port: v.port,
};
}
return result;
}
@@ -57,8 +98,18 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
const variants = config.cliproxy || {};
const result: Record<string, VariantConfig> = {};
for (const name of Object.keys(variants)) {
const v = variants[name] as { provider: string; settings: string; account?: string };
result[name] = { provider: v.provider, settings: v.settings, account: v.account };
const v = variants[name] as {
provider: string;
settings: string;
account?: string;
port?: number;
};
result[name] = {
provider: v.provider,
settings: v.settings,
account: v.account,
port: v.port,
};
}
return result;
} catch {
@@ -73,7 +124,8 @@ export function saveVariantUnified(
name: string,
provider: CLIProxyProvider,
settingsPath: string,
account?: string
account?: string,
port?: number
): void {
const config = loadOrCreateUnifiedConfig();
@@ -92,6 +144,7 @@ export function saveVariantUnified(
provider,
account,
settings: settingsPath,
port,
};
saveUnifiedConfig(config);
@@ -104,7 +157,8 @@ export function saveVariantLegacy(
name: string,
provider: string,
settingsPath: string,
account?: string
account?: string,
port?: number
): void {
const configPath = getConfigPath();
@@ -119,13 +173,16 @@ export function saveVariantLegacy(
config.cliproxy = {};
}
const variantConfig: { provider: string; settings: string; account?: string } = {
const variantConfig: { provider: string; settings: string; account?: string; port?: number } = {
provider,
settings: settingsPath,
};
if (account) {
variantConfig.account = account;
}
if (port) {
variantConfig.port = port;
}
config.cliproxy[name] = variantConfig;
const tempPath = configPath + '.tmp';
@@ -147,7 +204,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
delete config.cliproxy.variants[name];
saveUnifiedConfig(config);
return { provider: variant.provider, settings: variant.settings };
return { provider: variant.provider, settings: variant.settings, port: variant.port };
}
/**
@@ -167,7 +224,7 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul
return null;
}
const variant = config.cliproxy[name] as { provider: string; settings: string };
const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number };
delete config.cliproxy[name];
if (Object.keys(config.cliproxy).length === 0) {
+87 -5
View File
@@ -10,11 +10,14 @@ import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider } from '../types';
import { isReservedName } from '../../config/reserved-names';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config-generator';
import { deleteSessionLockForPort } from '../session-tracker';
import {
createSettingsFile,
createSettingsFileUnified,
deleteSettingsFile,
getRelativeSettingsPath,
updateSettingsModel,
} from './variant-settings';
import {
VariantConfig,
@@ -24,6 +27,7 @@ import {
saveVariantLegacy,
removeVariantFromUnifiedConfig,
removeVariantFromLegacyConfig,
getNextAvailablePort,
} from './variant-config-adapter';
// Re-export VariantConfig from adapter
@@ -80,25 +84,29 @@ export function createVariant(
account?: string
): VariantOperationResult {
try {
// Allocate unique port for this variant
const port = getNextAvailablePort();
let settingsPath: string;
if (isUnifiedMode()) {
settingsPath = createSettingsFileUnified(name, provider, model);
settingsPath = createSettingsFileUnified(name, provider, model, port);
saveVariantUnified(
name,
provider as CLIProxyProvider,
getRelativeSettingsPath(provider, name),
account
account,
port
);
} else {
settingsPath = createSettingsFile(name, provider, model);
saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account);
settingsPath = createSettingsFile(name, provider, model, port);
saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port);
}
return {
success: true,
settingsPath,
variant: { provider, model, account },
variant: { provider, model, account, port },
};
} catch (error) {
return {
@@ -120,12 +128,22 @@ export function removeVariant(name: string): VariantOperationResult {
if (unifiedVariant?.settings) {
deleteSettingsFile(unifiedVariant.settings);
}
// Clean up port-specific config and session files
if (unifiedVariant?.port) {
deleteConfigForPort(unifiedVariant.port);
deleteSessionLockForPort(unifiedVariant.port);
}
variant = unifiedVariant;
} else {
variant = removeVariantFromLegacyConfig(name);
if (variant?.settings) {
deleteSettingsFile(variant.settings);
}
// Clean up port-specific config and session files
if (variant?.port) {
deleteConfigForPort(variant.port);
deleteSessionLockForPort(variant.port);
}
}
if (!variant) {
@@ -137,3 +155,67 @@ export function removeVariant(name: string): VariantOperationResult {
return { success: false, error: (error as Error).message };
}
}
/** Update options for variant */
export interface UpdateVariantOptions {
provider?: CLIProxyProfileName;
account?: string;
model?: string;
}
/**
* Update an existing CLIProxy variant
*/
export function updateVariant(name: string, updates: UpdateVariantOptions): VariantOperationResult {
try {
const variants = listVariantsFromConfig();
const existing = variants[name];
if (!existing) {
return { success: false, error: `Variant '${name}' not found` };
}
// Update model in settings file if provided
if (updates.model !== undefined && existing.settings) {
const settingsPath = existing.settings.replace(/^~/, process.env.HOME || '');
updateSettingsModel(settingsPath, updates.model);
}
// Update config entry if provider or account changed
if (updates.provider !== undefined || updates.account !== undefined) {
const newProvider = updates.provider ?? existing.provider;
const newAccount = updates.account !== undefined ? updates.account : existing.account;
if (isUnifiedMode()) {
saveVariantUnified(
name,
newProvider as CLIProxyProvider,
existing.settings || '',
newAccount || undefined,
existing.port
);
} else {
saveVariantLegacy(
name,
newProvider,
existing.settings || '',
newAccount || undefined,
existing.port
);
}
}
return {
success: true,
variant: {
provider: updates.provider ?? existing.provider,
model: updates.model ?? existing.model,
account: updates.account !== undefined ? updates.account : existing.account,
port: existing.port,
settings: existing.settings,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
+42 -7
View File
@@ -30,8 +30,12 @@ interface SettingsFile {
/**
* Build settings env object for a variant
*/
function buildSettingsEnv(provider: CLIProxyProfileName, model: string): SettingsEnv {
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, CLIPROXY_DEFAULT_PORT);
function buildSettingsEnv(
provider: CLIProxyProfileName,
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): SettingsEnv {
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port);
return {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
@@ -87,13 +91,14 @@ export function getRelativeSettingsPath(provider: CLIProxyProfileName, name: str
export function createSettingsFile(
name: string,
provider: CLIProxyProfileName,
model: string
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = getCcsDir();
const settingsPath = getSettingsFilePath(provider, name);
const settings: SettingsFile = {
env: buildSettingsEnv(provider, model),
env: buildSettingsEnv(provider, model, port),
};
ensureDir(ccsDir);
@@ -108,13 +113,14 @@ export function createSettingsFile(
export function createSettingsFileUnified(
name: string,
provider: CLIProxyProfileName,
model: string
model: string,
port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = path.join(os.homedir(), '.ccs');
const ccsDir = getCcsDir(); // Use centralized function for CCS_HOME support
const settingsPath = path.join(ccsDir, getSettingsFileName(provider, name));
const settings: SettingsFile = {
env: buildSettingsEnv(provider, model),
env: buildSettingsEnv(provider, model, port),
};
ensureDir(ccsDir);
@@ -134,3 +140,32 @@ export function deleteSettingsFile(settingsPath: string): boolean {
}
return false;
}
/**
* Update model in an existing settings file
*/
export function updateSettingsModel(settingsPath: string, model: string): void {
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
if (!fs.existsSync(resolvedPath)) {
return;
}
try {
const content = fs.readFileSync(resolvedPath, 'utf8');
const settings = JSON.parse(content) as SettingsFile;
if (model) {
settings.env = settings.env || ({} as SettingsEnv);
settings.env.ANTHROPIC_MODEL = model;
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
} else {
// Clear model settings to use defaults
delete (settings.env as unknown as Record<string, string>).ANTHROPIC_MODEL;
}
fs.writeFileSync(resolvedPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
} catch {
// Ignore errors - settings file may be invalid
}
}
+96 -39
View File
@@ -34,14 +34,28 @@ function generateSessionId(): string {
return crypto.randomBytes(8).toString('hex');
}
/** Get path to session lock file */
function getSessionLockPath(): string {
return path.join(getCliproxyDir(), 'sessions.json');
/** Get path to session lock file for specific port */
function getSessionLockPathForPort(port: number): string {
if (port === CLIPROXY_DEFAULT_PORT) {
return path.join(getCliproxyDir(), 'sessions.json');
}
return path.join(getCliproxyDir(), `sessions-${port}.json`);
}
/** Read session lock file (returns null if not exists or invalid) */
function readSessionLock(): SessionLock | null {
const lockPath = getSessionLockPath();
/** Get path to session lock file (default port) - kept for future use */
function _getSessionLockPath(): string {
return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT);
}
// Re-export for external use
export { _getSessionLockPath as getSessionLockPath };
// Export deleteSessionLockForPort for cleanup operations
export { deleteSessionLockForPort };
/** Read session lock file for specific port (returns null if not exists or invalid) */
function readSessionLockForPort(port: number): SessionLock | null {
const lockPath = getSessionLockPathForPort(port);
try {
if (!fs.existsSync(lockPath)) {
return null;
@@ -62,9 +76,14 @@ function readSessionLock(): SessionLock | null {
}
}
/** Write session lock file */
function writeSessionLock(lock: SessionLock): void {
const lockPath = getSessionLockPath();
/** Read session lock file (default port, returns null if not exists or invalid) */
function readSessionLock(): SessionLock | null {
return readSessionLockForPort(CLIPROXY_DEFAULT_PORT);
}
/** Write session lock file for specific port */
function writeSessionLockForPort(lock: SessionLock): void {
const lockPath = getSessionLockPathForPort(lock.port);
const dir = path.dirname(lockPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -72,9 +91,9 @@ function writeSessionLock(lock: SessionLock): void {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
}
/** Delete session lock file */
function deleteSessionLock(): void {
const lockPath = getSessionLockPath();
/** Delete session lock file for specific port */
function deleteSessionLockForPort(port: number): void {
const lockPath = getSessionLockPathForPort(port);
try {
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
@@ -84,13 +103,24 @@ function deleteSessionLock(): void {
}
}
/** Delete session lock file (default port) */
function deleteSessionLock(): void {
deleteSessionLockForPort(CLIPROXY_DEFAULT_PORT);
}
/** Check if a PID is still running */
function isProcessRunning(pid: number): boolean {
try {
// Sending signal 0 checks if process exists without killing it
process.kill(pid, 0);
return true;
} catch {
} catch (err) {
const e = err as NodeJS.ErrnoException;
// EPERM means process exists but we don't have permission to signal it
if (e.code === 'EPERM') {
return true;
}
// ESRCH means no such process
return false;
}
}
@@ -100,7 +130,7 @@ function isProcessRunning(pid: number): boolean {
* Returns the existing lock if proxy is healthy, null otherwise.
*/
export function getExistingProxy(port: number): SessionLock | null {
const lock = readSessionLock();
const lock = readSessionLockForPort(port);
if (!lock) {
return null;
}
@@ -113,7 +143,7 @@ export function getExistingProxy(port: number): SessionLock | null {
// Verify proxy process is still running
if (!isProcessRunning(lock.pid)) {
// Proxy crashed - clean up stale lock
deleteSessionLock();
deleteSessionLockForPort(port);
return null;
}
@@ -127,12 +157,12 @@ export function getExistingProxy(port: number): SessionLock | null {
*/
export function registerSession(port: number, proxyPid: number): string {
const sessionId = generateSessionId();
const existingLock = readSessionLock();
const existingLock = readSessionLockForPort(port);
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
// Add to existing sessions
existingLock.sessions.push(sessionId);
writeSessionLock(existingLock);
writeSessionLockForPort(existingLock);
} else {
// Create new lock (first session for this proxy)
const newLock: SessionLock = {
@@ -141,7 +171,7 @@ export function registerSession(port: number, proxyPid: number): string {
sessions: [sessionId],
startedAt: new Date().toISOString(),
};
writeSessionLock(newLock);
writeSessionLockForPort(newLock);
}
return sessionId;
@@ -149,9 +179,33 @@ export function registerSession(port: number, proxyPid: number): string {
/**
* Unregister a session from the proxy.
* @param sessionId Session ID to unregister
* @param port Port to unregister from (optional, searches default port if not provided)
* @returns true if this was the last session (proxy should be killed)
*/
export function unregisterSession(sessionId: string): boolean {
export function unregisterSession(sessionId: string, port?: number): boolean {
// If port provided, use port-specific lookup
if (port !== undefined) {
const lock = readSessionLockForPort(port);
if (!lock) {
return true;
}
const index = lock.sessions.indexOf(sessionId);
if (index !== -1) {
lock.sessions.splice(index, 1);
}
if (lock.sessions.length === 0) {
deleteSessionLockForPort(port);
return true;
}
writeSessionLockForPort(lock);
return false;
}
// Fallback: search default port (backward compat)
const lock = readSessionLock();
if (!lock) {
// No lock file - assume we're the only session
@@ -172,15 +226,16 @@ export function unregisterSession(sessionId: string): boolean {
}
// Other sessions still active - keep proxy running
writeSessionLock(lock);
writeSessionLockForPort(lock);
return false;
}
/**
* Get current session count for the proxy.
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
*/
export function getSessionCount(): number {
const lock = readSessionLock();
export function getSessionCount(port: number = CLIPROXY_DEFAULT_PORT): number {
const lock = readSessionLockForPort(port);
if (!lock) {
return 0;
}
@@ -190,16 +245,17 @@ export function getSessionCount(): number {
/**
* Check if proxy has any active sessions.
* Used to determine if a "zombie" proxy should be killed.
* @param port Port to check (defaults to CLIPROXY_DEFAULT_PORT)
*/
export function hasActiveSessions(): boolean {
const lock = readSessionLock();
export function hasActiveSessions(port: number = CLIPROXY_DEFAULT_PORT): boolean {
const lock = readSessionLockForPort(port);
if (!lock) {
return false;
}
// Verify proxy is still running
if (!isProcessRunning(lock.pid)) {
deleteSessionLock();
deleteSessionLockForPort(port);
return false;
}
@@ -211,38 +267,39 @@ export function hasActiveSessions(): boolean {
* Called on startup to ensure clean state.
*/
export function cleanupOrphanedSessions(port: number): void {
const lock = readSessionLock();
const lock = readSessionLockForPort(port);
if (!lock) {
return;
}
// If port doesn't match, this lock is for a different proxy
// If port doesn't match, this shouldn't happen with port-specific files
if (lock.port !== port) {
return;
}
// If proxy is dead, clean up lock
if (!isProcessRunning(lock.pid)) {
deleteSessionLock();
deleteSessionLockForPort(port);
}
}
/**
* Stop the CLIProxy process and clean up session lock.
* Falls back to port-based detection if no session lock exists.
* @param port Port to stop (defaults to CLIPROXY_DEFAULT_PORT)
* @returns Object with success status and details
*/
export async function stopProxy(): Promise<{
export async function stopProxy(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
stopped: boolean;
pid?: number;
sessionCount?: number;
error?: string;
}> {
const lock = readSessionLock();
const lock = readSessionLockForPort(port);
if (!lock) {
// No session lock - try to find process by port (legacy/untracked proxy)
const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
const portProcess = await getPortProcess(port);
if (!portProcess) {
return { stopped: false, error: 'No active CLIProxy session found' };
@@ -251,7 +308,7 @@ export async function stopProxy(): Promise<{
if (!isCLIProxyProcess(portProcess)) {
return {
stopped: false,
error: `Port ${CLIPROXY_DEFAULT_PORT} is in use by ${portProcess.processName}, not CLIProxy`,
error: `Port ${port} is in use by ${portProcess.processName}, not CLIProxy`,
};
}
@@ -270,7 +327,7 @@ export async function stopProxy(): Promise<{
// Check if proxy is running
if (!isProcessRunning(lock.pid)) {
deleteSessionLock();
deleteSessionLockForPort(port);
return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' };
}
@@ -282,14 +339,14 @@ export async function stopProxy(): Promise<{
process.kill(pid, 'SIGTERM');
// Clean up session lock
deleteSessionLock();
deleteSessionLockForPort(port);
return { stopped: true, pid, sessionCount };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
// Process already gone
deleteSessionLock();
deleteSessionLockForPort(port);
return { stopped: false, error: 'CLIProxy process already terminated' };
}
return { stopped: false, pid, error: `Failed to stop: ${error.message}` };
@@ -297,16 +354,16 @@ export async function stopProxy(): Promise<{
}
/**
* Get proxy status information.
* Get proxy status information for specific port.
*/
export function getProxyStatus(): {
export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
running: boolean;
port?: number;
pid?: number;
sessionCount?: number;
startedAt?: string;
} {
const lock = readSessionLock();
const lock = readSessionLockForPort(port);
if (!lock) {
return { running: false };
@@ -314,7 +371,7 @@ export function getProxyStatus(): {
// Verify proxy is still running
if (!isProcessRunning(lock.pid)) {
deleteSessionLock();
deleteSessionLockForPort(port);
return { running: false };
}
+8 -3
View File
@@ -256,9 +256,10 @@ async function handleCreate(args: string[]): Promise<void> {
const settingsDisplay = isUnifiedMode()
? '~/.ccs/config.yaml'
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
console.log(
infoBox(
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
configType
)
);
@@ -303,10 +304,11 @@ async function handleList(): Promise<void> {
console.log(subheader('Custom Variants'));
const rows = variantNames.map((name) => {
const variant = variants[name];
return [name, variant.provider, variant.settings || '-'];
const portStr = variant.port ? String(variant.port) : '-';
return [name, variant.provider, portStr, variant.settings || '-'];
});
console.log(
table(rows, { head: ['Variant', 'Provider', 'Settings'], colWidths: [15, 12, 35] })
table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] })
);
console.log('');
console.log(dim(`Total: ${variantNames.length} custom variant(s)`));
@@ -357,6 +359,9 @@ async function handleRemove(args: string[]): Promise<void> {
console.log('');
console.log(`Variant '${color(name, 'command')}' will be removed.`);
console.log(` Provider: ${variant.provider}`);
if (variant.port) {
console.log(` Port: ${variant.port}`);
}
console.log(` Settings: ${variant.settings || '-'}`);
console.log('');
+2
View File
@@ -63,6 +63,8 @@ export interface CLIProxyVariantConfig {
account?: string;
/** Path to settings file (e.g., "~/.ccs/gemini-custom.settings.json") */
settings?: string;
/** Unique port for variant isolation (8318-8417) */
port?: number;
}
/**
+2
View File
@@ -23,6 +23,8 @@ export interface CLIProxyVariantConfig {
settings: string;
/** Account identifier for multi-account support (optional, defaults to 'default') */
account?: string;
/** Unique port for variant isolation (8318-8417) */
port?: number;
}
/**
-33
View File
@@ -7,8 +7,6 @@ import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import type { Config, Settings } from '../../types/config';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { getClaudeEnvVars } from '../../cliproxy/config-generator';
/** Model mapping for API profiles */
export interface ModelMapping {
@@ -156,37 +154,6 @@ export function updateSettingsFile(
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
/**
* Create cliproxy variant settings
* Includes base URL and auth token for proper Claude CLI integration
*/
export function createCliproxySettings(
name: string,
provider: CLIProxyProvider,
model?: string
): string {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
// Get base env vars from provider config (includes BASE_URL, AUTH_TOKEN)
const baseEnv = getClaudeEnvVars(provider);
const settings: Settings = {
env: {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: model || (baseEnv.ANTHROPIC_MODEL as string) || '',
ANTHROPIC_DEFAULT_OPUS_MODEL: model || (baseEnv.ANTHROPIC_DEFAULT_OPUS_MODEL as string) || '',
ANTHROPIC_DEFAULT_SONNET_MODEL:
model || (baseEnv.ANTHROPIC_DEFAULT_SONNET_MODEL as string) || '',
ANTHROPIC_DEFAULT_HAIKU_MODEL:
(baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL as string) || model || '',
},
};
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
return `~/.ccs/${name}.settings.json`;
}
/**
* Security: Validate file path is within allowed directories
* - ~/.ccs/ directory: read/write allowed
+27 -11
View File
@@ -7,10 +7,30 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { isSensitiveKey, maskSensitiveValue } from '../../utils/sensitive-keys';
import { listVariants } from '../../cliproxy/services/variant-service';
import type { Settings } from '../../types/config';
const router = Router();
/**
* Helper: Resolve settings path for profile or variant
* Variants have settings paths in config, regular profiles use {name}.settings.json
*/
function resolveSettingsPath(profileOrVariant: string): string {
const ccsDir = getCcsDir();
// Check if this is a variant
const variants = listVariants();
const variant = variants[profileOrVariant];
if (variant?.settings) {
// Variant settings path (e.g., ~/.ccs/agy-g3.settings.json)
return variant.settings.replace(/^~/, process.env.HOME || '');
}
// Regular profile settings
return path.join(ccsDir, `${profileOrVariant}.settings.json`);
}
/**
* Helper: Mask API keys in settings
*/
@@ -34,8 +54,7 @@ function maskApiKeys(settings: Settings): Settings {
router.get('/:profile', (req: Request, res: Response): void => {
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
@@ -63,8 +82,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
router.get('/:profile/raw', (req: Request, res: Response): void => {
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
@@ -93,7 +111,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const { settings, expectedMtime } = req.body;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const settingsPath = resolveSettingsPath(profile);
const fileExists = fs.existsSync(settingsPath);
@@ -151,8 +169,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
router.get('/:profile/presets', (req: Request, res: Response): void => {
try {
const { profile } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.json({ presets: [] });
@@ -179,11 +196,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
return;
}
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const settingsPath = resolveSettingsPath(profile);
// Create settings file if it doesn't exist
if (!fs.existsSync(settingsPath)) {
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
}
@@ -219,8 +236,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
try {
const { profile, name } = req.params;
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
+51 -82
View File
@@ -1,34 +1,43 @@
/**
* Variant Routes - CLIProxy variant management (custom profiles)
*
* Uses variant-service.ts for proper port allocation and cleanup.
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadSettings } from '../../utils/config-manager';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { readConfigSafe, writeConfig, createCliproxySettings } from './route-helpers';
import {
createVariant,
removeVariant,
listVariants,
validateProfileName,
updateVariant,
} from '../../cliproxy/services/variant-service';
const router = Router();
/**
* GET /api/cliproxy - List cliproxy variants
* Uses variant-service for consistent behavior with CLI
*/
router.get('/', (_req: Request, res: Response) => {
const config = readConfigSafe();
const variants = Object.entries(config.cliproxy || {}).map(([name, variant]) => ({
const variants = listVariants();
const variantList = Object.entries(variants).map(([name, variant]) => ({
name,
provider: variant.provider,
settings: variant.settings,
account: variant.account || 'default', // Include account field
account: variant.account || 'default',
port: variant.port, // Include port for port isolation
model: variant.model,
}));
res.json({ variants });
res.json({ variants: variantList });
});
/**
* POST /api/cliproxy - Create cliproxy variant
* Uses variant-service for proper port allocation
*/
router.post('/', (req: Request, res: Response): void => {
const { name, provider, model, account } = req.body;
@@ -38,7 +47,14 @@ router.post('/', (req: Request, res: Response): void => {
return;
}
// Reject reserved names as variant names (prevents collision with built-in providers)
// Validate profile name
const validationError = validateProfileName(name);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
// Reject reserved names (extra safety check)
if (isReservedName(name)) {
res.status(400).json({
error: `Cannot use reserved name '${name}' as variant name`,
@@ -47,84 +63,47 @@ router.post('/', (req: Request, res: Response): void => {
return;
}
const config = readConfigSafe();
config.cliproxy = config.cliproxy || {};
// Use variant-service for proper port allocation
const result = createVariant(name, provider as CLIProxyProvider, model || '', account);
if (config.cliproxy[name]) {
res.status(409).json({ error: 'Variant already exists' });
if (!result.success) {
res.status(409).json({ error: result.error });
return;
}
// Ensure .ccs directory exists
if (!fs.existsSync(getCcsDir())) {
fs.mkdirSync(getCcsDir(), { recursive: true });
}
// Create settings file for variant
const settingsPath = createCliproxySettings(name, provider as CLIProxyProvider, model);
// Include account if specified (defaults to 'default' if not provided)
config.cliproxy[name] = {
res.status(201).json({
name,
provider,
settings: settingsPath,
...(account && { account }),
};
writeConfig(config);
res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' });
settings: result.settingsPath,
account: account || 'default',
port: result.variant?.port,
model: result.variant?.model,
});
});
/**
* PUT /api/cliproxy/:name - Update cliproxy variant
* Uses variant-service for consistent behavior with CLI
*/
router.put('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
const { provider, account, model } = req.body;
const config = readConfigSafe();
// Use variant-service for proper update handling
const result = updateVariant(name, { provider, account, model });
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
if (!result.success) {
res.status(404).json({ error: result.error });
return;
}
const variant = config.cliproxy[name];
// Update fields if provided
if (provider) {
variant.provider = provider;
}
if (account !== undefined) {
if (account) {
variant.account = account;
} else {
delete variant.account; // Remove account to use default
}
}
// Update model in settings file if provided
if (model !== undefined) {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
const settings = loadSettings(settingsPath);
if (model) {
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
} else if (settings.env) {
delete settings.env.ANTHROPIC_MODEL;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
}
writeConfig(config);
res.json({
name,
provider: variant.provider,
account: variant.account || 'default',
settings: variant.settings,
provider: result.variant?.provider,
account: result.variant?.account || 'default',
settings: result.variant?.settings,
port: result.variant?.port,
updated: true,
});
} catch (error) {
@@ -134,31 +113,21 @@ router.put('/:name', (req: Request, res: Response): void => {
/**
* DELETE /api/cliproxy/:name - Delete cliproxy variant
* Uses variant-service for proper port-specific file cleanup
*/
router.delete('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
const config = readConfigSafe();
// Use variant-service for proper cleanup (settings, config, session files)
const result = removeVariant(name);
if (!config.cliproxy?.[name]) {
res.status(404).json({ error: 'Variant not found' });
if (!result.success) {
res.status(404).json({ error: result.error });
return;
}
// Never delete settings files for reserved provider names (safety guard)
if (!isReservedName(name)) {
// Only delete settings file for non-reserved variant names
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
if (fs.existsSync(settingsPath)) {
fs.unlinkSync(settingsPath);
}
}
delete config.cliproxy[name];
writeConfig(config);
res.json({ name, deleted: true });
res.json({ name, deleted: true, port: result.variant?.port });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -0,0 +1,280 @@
/**
* Config Generator Port Tests
*
* Tests for per-port configuration in config-generator.ts.
* Verifies port-specific config files (config-{port}.yaml) and path generation.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Set test isolation environment before importing
const testHome = path.join(
os.tmpdir(),
`ccs-test-config-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
process.env.CCS_HOME = testHome;
const {
getConfigPathForPort,
getConfigPath,
generateConfig,
regenerateConfig,
configExists,
deleteConfigForPort,
deleteConfig,
CLIPROXY_DEFAULT_PORT,
} = require('../../../dist/cliproxy/config-generator');
describe('Config Generator Port', function () {
let cliproxyDir;
beforeEach(function () {
// Create test directories
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
// Clean up any existing config files
try {
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
if (file.startsWith('config')) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
}
} catch {
// Directory might not exist yet
}
});
afterEach(function () {
// Clean up config files
try {
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
if (file.startsWith('config')) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
}
} catch {
// Ignore cleanup errors
}
});
afterAll(function () {
// Clean up test directory
try {
fs.rmSync(testHome, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
delete process.env.CCS_HOME;
});
describe('getConfigPathForPort', function () {
it('returns config.yaml for default port (8317)', function () {
const configPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
const filename = path.basename(configPath);
assert.ok(configPath.endsWith('config.yaml'), `Expected path to end with config.yaml but got: ${configPath}`);
assert.strictEqual(filename, 'config.yaml', `Expected filename to be config.yaml but got: ${filename}`);
});
it('returns config-{port}.yaml for variant ports', function () {
const variantPort = 8318;
const configPath = getConfigPathForPort(variantPort);
assert.ok(configPath.endsWith(`config-${variantPort}.yaml`));
});
it('example: port 8318 -> config-8318.yaml', function () {
const configPath = getConfigPathForPort(8318);
assert.ok(configPath.endsWith('config-8318.yaml'));
});
it('example: port 8417 -> config-8417.yaml', function () {
const configPath = getConfigPathForPort(8417);
assert.ok(configPath.endsWith('config-8417.yaml'));
});
});
describe('getConfigPath', function () {
it('returns path for default port', function () {
const configPath = getConfigPath();
const defaultPath = getConfigPathForPort(CLIPROXY_DEFAULT_PORT);
assert.strictEqual(configPath, defaultPath);
});
});
describe('generateConfig', function () {
it('creates config-{port}.yaml for non-default port', function () {
const variantPort = 8318;
generateConfig('gemini', variantPort);
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
assert.ok(fs.existsSync(configPath), 'Should create config-8318.yaml');
});
it('creates config.yaml for default port', function () {
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
const configPath = path.join(cliproxyDir, 'config.yaml');
assert.ok(fs.existsSync(configPath), 'Should create config.yaml');
});
it('only creates if file does not exist (idempotent)', function () {
const variantPort = 8318;
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
// Create config first time
generateConfig('gemini', variantPort);
const stat1 = fs.statSync(configPath);
// Wait a tiny bit and try again
const originalContent = fs.readFileSync(configPath, 'utf-8');
generateConfig('gemini', variantPort);
const newContent = fs.readFileSync(configPath, 'utf-8');
// Content should be the same (not regenerated)
assert.strictEqual(originalContent, newContent);
});
it('sets correct port in config content', function () {
const variantPort = 8320;
generateConfig('gemini', variantPort);
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
const content = fs.readFileSync(configPath, 'utf-8');
// Check that port is set correctly
assert.ok(content.includes(`port: ${variantPort}`));
});
});
describe('regenerateConfig', function () {
it('regenerates config-{port}.yaml with updated version', function () {
const variantPort = 8318;
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
// Create initial config
generateConfig('gemini', variantPort);
const originalContent = fs.readFileSync(configPath, 'utf-8');
// Modify the file
fs.writeFileSync(configPath, '# modified content\n' + originalContent);
// Regenerate
regenerateConfig(variantPort);
const newContent = fs.readFileSync(configPath, 'utf-8');
// Should not contain our modification
assert.ok(!newContent.includes('# modified content'));
});
it('preserves port value from existing config', function () {
const variantPort = 8325;
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
// Create config with specific port
generateConfig('gemini', variantPort);
// Regenerate
regenerateConfig(variantPort);
const content = fs.readFileSync(configPath, 'utf-8');
// Port should still be 8325
assert.ok(content.includes(`port: ${variantPort}`));
});
});
describe('deleteConfigForPort', function () {
it('deletes config-{port}.yaml for specified port', function () {
const variantPort = 8318;
generateConfig('gemini', variantPort);
const configPath = path.join(cliproxyDir, `config-${variantPort}.yaml`);
assert.ok(fs.existsSync(configPath));
deleteConfigForPort(variantPort);
assert.strictEqual(fs.existsSync(configPath), false);
});
it('does nothing if file does not exist', function () {
// Should not throw
deleteConfigForPort(8399);
});
it('does not affect other port configs', function () {
const port1 = 8318;
const port2 = 8319;
generateConfig('gemini', port1);
generateConfig('gemini', port2);
deleteConfigForPort(port1);
const config1Path = path.join(cliproxyDir, `config-${port1}.yaml`);
const config2Path = path.join(cliproxyDir, `config-${port2}.yaml`);
assert.strictEqual(fs.existsSync(config1Path), false);
assert.ok(fs.existsSync(config2Path));
});
});
describe('deleteConfig', function () {
it('deletes config.yaml for default port', function () {
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
const configPath = path.join(cliproxyDir, 'config.yaml');
assert.ok(fs.existsSync(configPath));
deleteConfig();
assert.strictEqual(fs.existsSync(configPath), false);
});
});
describe('configExists', function () {
it('returns true if config-{port}.yaml exists', function () {
const variantPort = 8318;
generateConfig('gemini', variantPort);
assert.strictEqual(configExists(variantPort), true);
});
it('returns false if config-{port}.yaml missing', function () {
const variantPort = 8399;
assert.strictEqual(configExists(variantPort), false);
});
it('returns true for default port config', function () {
generateConfig('gemini', CLIPROXY_DEFAULT_PORT);
assert.strictEqual(configExists(CLIPROXY_DEFAULT_PORT), true);
});
});
describe('Multiple Port Configs', function () {
it('can create configs for multiple ports simultaneously', function () {
const ports = [8318, 8319, 8320, CLIPROXY_DEFAULT_PORT];
for (const port of ports) {
generateConfig('gemini', port);
}
// All should exist
for (const port of ports) {
assert.ok(configExists(port), `Config for port ${port} should exist`);
}
});
it('each port config has correct port value', function () {
const ports = [8318, 8319, 8320];
for (const port of ports) {
generateConfig('gemini', port);
const configPath = getConfigPathForPort(port);
const content = fs.readFileSync(configPath, 'utf-8');
assert.ok(content.includes(`port: ${port}`), `Config should have port: ${port}`);
}
});
});
});
@@ -0,0 +1,420 @@
/**
* Session Tracker Port-Specific Tests
*
* Tests for per-port session tracking in session-tracker.ts.
* Verifies port-specific session files (sessions-{port}.json) and cleanup.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Set test isolation environment before importing
const testHome = path.join(
os.tmpdir(),
`ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
process.env.CCS_HOME = testHome;
const {
getExistingProxy,
registerSession,
unregisterSession,
cleanupOrphanedSessions,
stopProxy,
getProxyStatus,
getSessionLockPath,
deleteSessionLockForPort,
} = require('../../../dist/cliproxy/session-tracker');
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
describe('Session Tracker Port-Specific', function () {
const variantPort1 = 8318;
const variantPort2 = 8319;
let cliproxyDir;
beforeEach(function () {
// Create test directories
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
// Clean up any existing session files
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
if (file.startsWith('sessions')) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
}
});
afterEach(function () {
// Clean up session files
try {
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
if (file.startsWith('sessions')) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
}
} catch {
// Ignore cleanup errors
}
});
afterAll(function () {
// Clean up test directory
try {
fs.rmSync(testHome, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
delete process.env.CCS_HOME;
});
describe('Session Lock Path', function () {
it('returns sessions.json for default port', function () {
const lockPath = getSessionLockPath();
assert.ok(lockPath.endsWith('sessions.json'));
assert.ok(!lockPath.includes('sessions-'));
});
});
describe('Port-Specific Session Files', function () {
it('creates sessions-{port}.json for variant ports', function () {
registerSession(variantPort1, process.pid);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
assert.ok(fs.existsSync(lockPath), `Should create sessions-${variantPort1}.json`);
});
it('creates sessions.json for default port', function () {
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
const lockPath = path.join(cliproxyDir, 'sessions.json');
assert.ok(fs.existsSync(lockPath), 'Should create sessions.json for default port');
});
it('keeps separate session files for different ports', function () {
// Register sessions on different ports
registerSession(variantPort1, process.pid);
registerSession(variantPort2, process.pid);
registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
// All three should exist
assert.ok(
fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort1}.json`)),
'Should have port 8318 sessions'
);
assert.ok(
fs.existsSync(path.join(cliproxyDir, `sessions-${variantPort2}.json`)),
'Should have port 8319 sessions'
);
assert.ok(
fs.existsSync(path.join(cliproxyDir, 'sessions.json')),
'Should have default port sessions'
);
});
});
describe('registerSession with Port', function () {
it('stores correct port in session lock file', function () {
registerSession(variantPort1, process.pid);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
assert.strictEqual(lock.port, variantPort1);
});
it('stores correct PID in session lock file', function () {
registerSession(variantPort1, process.pid);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
assert.strictEqual(lock.pid, process.pid);
});
it('appends to existing sessions array for same port', function () {
const session1 = registerSession(variantPort1, process.pid);
const session2 = registerSession(variantPort1, process.pid);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
assert.strictEqual(lock.sessions.length, 2);
assert.ok(lock.sessions.includes(session1));
assert.ok(lock.sessions.includes(session2));
});
});
describe('unregisterSession with Port', function () {
it('removes session from port-specific file', function () {
const session1 = registerSession(variantPort1, process.pid);
const session2 = registerSession(variantPort1, process.pid);
// Unregister first session with port
unregisterSession(session1, variantPort1);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
assert.strictEqual(lock.sessions.length, 1);
assert.strictEqual(lock.sessions[0], session2);
});
it('deletes lock file when last session removed', function () {
const session = registerSession(variantPort1, process.pid);
const shouldKill = unregisterSession(session, variantPort1);
assert.strictEqual(shouldKill, true);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
assert.strictEqual(fs.existsSync(lockPath), false);
});
it('returns true when last session', function () {
const session = registerSession(variantPort1, process.pid);
const shouldKill = unregisterSession(session, variantPort1);
assert.strictEqual(shouldKill, true);
});
it('returns false when sessions remain', function () {
const session1 = registerSession(variantPort1, process.pid);
registerSession(variantPort1, process.pid);
const shouldKill = unregisterSession(session1, variantPort1);
assert.strictEqual(shouldKill, false);
});
it('searches default port for backward compat (fallback)', function () {
// Register on default port
const session = registerSession(CLIPROXY_DEFAULT_PORT, process.pid);
// Unregister without port (should search default)
const shouldKill = unregisterSession(session);
assert.strictEqual(shouldKill, true);
const lockPath = path.join(cliproxyDir, 'sessions.json');
assert.strictEqual(fs.existsSync(lockPath), false);
});
});
describe('getExistingProxy with Port', function () {
it('returns lock for running proxy on specified port', function () {
registerSession(variantPort1, process.pid);
const lock = getExistingProxy(variantPort1);
assert.notStrictEqual(lock, null);
assert.strictEqual(lock.port, variantPort1);
assert.strictEqual(lock.pid, process.pid);
});
it('returns null if lock file missing', function () {
const lock = getExistingProxy(variantPort1);
assert.strictEqual(lock, null);
});
it('returns null if port mismatch', function () {
// Create lock with different port number in file
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
fs.writeFileSync(
lockPath,
JSON.stringify({
port: 9999, // Wrong port
pid: process.pid,
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
const lock = getExistingProxy(variantPort1);
assert.strictEqual(lock, null);
});
it('cleans up stale lock if PID not running', function () {
// Create lock with dead PID
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
fs.writeFileSync(
lockPath,
JSON.stringify({
port: variantPort1,
pid: 999999999, // Dead PID
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
const lock = getExistingProxy(variantPort1);
assert.strictEqual(lock, null);
assert.strictEqual(fs.existsSync(lockPath), false);
});
});
describe('deleteSessionLockForPort', function () {
it('removes sessions-{port}.json for specified port', function () {
registerSession(variantPort1, process.pid);
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
assert.ok(fs.existsSync(lockPath));
deleteSessionLockForPort(variantPort1);
assert.strictEqual(fs.existsSync(lockPath), false);
});
it('does nothing if file does not exist', function () {
// Should not throw
deleteSessionLockForPort(variantPort1);
});
it('does not affect other port sessions', function () {
registerSession(variantPort1, process.pid);
registerSession(variantPort2, process.pid);
deleteSessionLockForPort(variantPort1);
const lock1Path = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
const lock2Path = path.join(cliproxyDir, `sessions-${variantPort2}.json`);
assert.strictEqual(fs.existsSync(lock1Path), false);
assert.ok(fs.existsSync(lock2Path));
});
});
describe('cleanupOrphanedSessions with Port', function () {
it('deletes lock if PID not running', function () {
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
fs.writeFileSync(
lockPath,
JSON.stringify({
port: variantPort1,
pid: 999999999, // Dead PID
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
cleanupOrphanedSessions(variantPort1);
assert.strictEqual(fs.existsSync(lockPath), false);
});
it('keeps lock if PID still running', function () {
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
fs.writeFileSync(
lockPath,
JSON.stringify({
port: variantPort1,
pid: process.pid, // Our process - running
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
cleanupOrphanedSessions(variantPort1);
assert.ok(fs.existsSync(lockPath));
});
});
describe('stopProxy with Port', function () {
it('stops proxy on specified port', async function () {
// Create lock with dead PID (we can't actually stop a real process in tests)
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
fs.writeFileSync(
lockPath,
JSON.stringify({
port: variantPort1,
pid: 999999999, // Dead PID
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
const result = await stopProxy(variantPort1);
assert.strictEqual(result.stopped, false);
assert.ok(result.error.includes('not running'));
});
it('cleans up session lock after stop', async function () {
const lockPath = path.join(cliproxyDir, `sessions-${variantPort1}.json`);
fs.writeFileSync(
lockPath,
JSON.stringify({
port: variantPort1,
pid: 999999999, // Dead PID
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
await stopProxy(variantPort1);
assert.strictEqual(fs.existsSync(lockPath), false);
});
it('handles already-stopped proxy gracefully', async function () {
const result = await stopProxy(variantPort1);
assert.strictEqual(result.stopped, false);
assert.strictEqual(result.error, 'No active CLIProxy session found');
});
});
describe('getProxyStatus with Port', function () {
it('returns correct status for variant port', function () {
registerSession(variantPort1, process.pid);
const status = getProxyStatus(variantPort1);
assert.strictEqual(status.running, true);
assert.strictEqual(status.port, variantPort1);
assert.strictEqual(status.pid, process.pid);
assert.strictEqual(status.sessionCount, 1);
});
it('returns not running for empty variant port', function () {
const status = getProxyStatus(variantPort1);
assert.strictEqual(status.running, false);
});
});
describe('Concurrent Variant Sessions', function () {
it('manages multiple variant ports independently', function () {
// Start sessions on different ports
const session1 = registerSession(variantPort1, process.pid);
const session2 = registerSession(variantPort2, process.pid);
// Both should be tracked
assert.strictEqual(getProxyStatus(variantPort1).running, true);
assert.strictEqual(getProxyStatus(variantPort2).running, true);
// Unregister one should not affect other
unregisterSession(session1, variantPort1);
assert.strictEqual(getProxyStatus(variantPort1).running, false);
assert.strictEqual(getProxyStatus(variantPort2).running, true);
// Clean up
unregisterSession(session2, variantPort2);
});
it('allows same session workflow on different ports', function () {
// Simulate concurrent variant usage
const port1Session1 = registerSession(variantPort1, process.pid);
const port1Session2 = registerSession(variantPort1, process.pid);
const port2Session1 = registerSession(variantPort2, process.pid);
assert.strictEqual(getProxyStatus(variantPort1).sessionCount, 2);
assert.strictEqual(getProxyStatus(variantPort2).sessionCount, 1);
// Unregister from port1
const shouldKill1 = unregisterSession(port1Session1, variantPort1);
assert.strictEqual(shouldKill1, false); // Still has session2
const shouldKill2 = unregisterSession(port1Session2, variantPort1);
assert.strictEqual(shouldKill2, true); // Last session on port1
// Port2 should still be running
assert.strictEqual(getProxyStatus(variantPort2).running, true);
// Clean up
unregisterSession(port2Session1, variantPort2);
});
});
});
+59 -39
View File
@@ -28,23 +28,39 @@ const {
describe('Session Tracker', function () {
const testPort = 18317;
let sessionLockPath;
let cliproxyDir;
beforeEach(function () {
// Create test directories
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
// Use port-specific session file for non-default ports
sessionLockPath = path.join(cliproxyDir, `sessions-${testPort}.json`);
// Clean up any existing lock file
if (fs.existsSync(sessionLockPath)) {
fs.unlinkSync(sessionLockPath);
// Clean up any existing lock files
try {
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
if (file.startsWith('sessions')) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
}
} catch {
// Directory might not exist yet
}
});
afterEach(function () {
// Clean up lock file
if (fs.existsSync(sessionLockPath)) {
fs.unlinkSync(sessionLockPath);
// Clean up lock files
try {
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
if (file.startsWith('sessions')) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
}
} catch {
// Ignore cleanup errors
}
});
@@ -164,7 +180,7 @@ describe('Session Tracker', function () {
describe('unregisterSession', function () {
it('should return true when no lock exists', function () {
const result = unregisterSession('nonexistent');
const result = unregisterSession('nonexistent', testPort);
assert.strictEqual(result, true);
});
@@ -174,7 +190,7 @@ describe('Session Tracker', function () {
const session2 = registerSession(testPort, process.pid);
// Unregister first
const shouldKill = unregisterSession(session1);
const shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
@@ -188,7 +204,7 @@ describe('Session Tracker', function () {
const session1 = registerSession(testPort, process.pid);
// Unregister it
const shouldKill = unregisterSession(session1);
const shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, true, 'should kill - last session');
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
@@ -199,38 +215,40 @@ describe('Session Tracker', function () {
registerSession(testPort, process.pid);
// Try to unregister wrong session
const shouldKill = unregisterSession('wrong-session-id');
const shouldKill = unregisterSession('wrong-session-id', testPort);
// Should return false since a session still exists
assert.strictEqual(shouldKill, false);
});
});
describe('getSessionCount', function () {
describe('getSessionCount (default port)', function () {
it('should return 0 when no lock exists', function () {
assert.strictEqual(getSessionCount(), 0);
});
it('should return correct count', function () {
it('should return correct count (uses getProxyStatus for port-specific)', function () {
registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 1);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 2);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 3);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
});
});
describe('hasActiveSessions', function () {
describe('hasActiveSessions (default port)', function () {
it('should return false when no lock exists', function () {
assert.strictEqual(hasActiveSessions(), false);
});
it('should return true when sessions exist and proxy running', function () {
it('should return true when sessions exist on default port', function () {
// Note: hasActiveSessions() checks default port only
// For port-specific checks, use getProxyStatus(port).running
registerSession(testPort, process.pid);
assert.strictEqual(hasActiveSessions(), true);
assert.strictEqual(getProxyStatus(testPort).running, true);
});
it('should return false and cleanup when proxy is dead', function () {
@@ -243,7 +261,9 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
assert.strictEqual(hasActiveSessions(), false);
// getProxyStatus will clean up stale lock
const status = getProxyStatus(testPort);
assert.strictEqual(status.running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
});
@@ -300,7 +320,7 @@ describe('Session Tracker', function () {
describe('stopProxy', function () {
it('should return error when no lock exists', async function () {
const result = await stopProxy();
const result = await stopProxy(testPort);
assert.strictEqual(result.stopped, false);
assert.strictEqual(result.error, 'No active CLIProxy session found');
});
@@ -315,7 +335,7 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = await stopProxy();
const result = await stopProxy(testPort);
assert.strictEqual(result.stopped, false);
assert.ok(result.error.includes('not running'));
assert.strictEqual(fs.existsSync(sessionLockPath), false);
@@ -327,7 +347,7 @@ describe('Session Tracker', function () {
// Note: We can't actually test killing our own process,
// but we can verify the structure is correct before it attempts kill
const status = getProxyStatus();
const status = getProxyStatus(testPort);
assert.strictEqual(status.running, true);
assert.strictEqual(status.pid, process.pid);
assert.strictEqual(status.sessionCount, 1);
@@ -336,7 +356,7 @@ describe('Session Tracker', function () {
describe('getProxyStatus', function () {
it('should return not running when no lock exists', function () {
const result = getProxyStatus();
const result = getProxyStatus(testPort);
assert.strictEqual(result.running, false);
assert.strictEqual(result.port, undefined);
assert.strictEqual(result.pid, undefined);
@@ -352,7 +372,7 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = getProxyStatus();
const result = getProxyStatus(testPort);
assert.strictEqual(result.running, true);
assert.strictEqual(result.port, testPort);
assert.strictEqual(result.pid, process.pid);
@@ -369,22 +389,22 @@ describe('Session Tracker', function () {
};
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
const result = getProxyStatus();
const result = getProxyStatus(testPort);
assert.strictEqual(result.running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
it('should return correct session count after registrations', function () {
registerSession(testPort, process.pid);
let status = getProxyStatus();
let status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 1);
registerSession(testPort, process.pid);
status = getProxyStatus();
status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 2);
registerSession(testPort, process.pid);
status = getProxyStatus();
status = getProxyStatus(testPort);
assert.strictEqual(status.sessionCount, 3);
});
});
@@ -393,30 +413,30 @@ describe('Session Tracker', function () {
it('should handle complete multi-terminal workflow', function () {
// Terminal 1 starts - first session
const session1 = registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 1);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
// Terminal 2 starts - joins existing
const session2 = registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 2);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
// Terminal 3 starts - joins existing
const session3 = registerSession(testPort, process.pid);
assert.strictEqual(getSessionCount(), 3);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 3);
// Terminal 1 exits - should NOT kill proxy
let shouldKill = unregisterSession(session1);
let shouldKill = unregisterSession(session1, testPort);
assert.strictEqual(shouldKill, false);
assert.strictEqual(getSessionCount(), 2);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 2);
// Terminal 3 exits - should NOT kill proxy
shouldKill = unregisterSession(session3);
shouldKill = unregisterSession(session3, testPort);
assert.strictEqual(shouldKill, false);
assert.strictEqual(getSessionCount(), 1);
assert.strictEqual(getProxyStatus(testPort).sessionCount, 1);
// Terminal 2 exits - SHOULD kill proxy (last session)
shouldKill = unregisterSession(session2);
shouldKill = unregisterSession(session2, testPort);
assert.strictEqual(shouldKill, true);
assert.strictEqual(getSessionCount(), 0);
assert.strictEqual(getProxyStatus(testPort).running, false);
assert.strictEqual(fs.existsSync(sessionLockPath), false);
});
});
@@ -0,0 +1,299 @@
/**
* Variant Port Allocation Tests
*
* Tests for port allocation logic in variant-config-adapter.ts.
* Verifies unique port assignment (8318-8417) for CLIProxy variants.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Set test isolation environment before importing
const testHome = path.join(
os.tmpdir(),
`ccs-test-port-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
process.env.CCS_HOME = testHome;
const {
getNextAvailablePort,
VARIANT_PORT_BASE,
VARIANT_PORT_MAX_OFFSET,
listVariantsFromConfig,
saveVariantLegacy,
removeVariantFromLegacyConfig,
} = require('../../../dist/cliproxy/services/variant-config-adapter');
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
describe('Variant Port Allocation', function () {
let configPath;
beforeEach(function () {
// Create test directories
const ccsDir = path.join(testHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
configPath = path.join(ccsDir, 'config.json');
// Start with empty config
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
});
afterEach(function () {
// Clean up config file
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
}
});
afterAll(function () {
// Clean up test directory
try {
fs.rmSync(testHome, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
delete process.env.CCS_HOME;
});
describe('Constants', function () {
it('VARIANT_PORT_BASE equals CLIPROXY_DEFAULT_PORT + 1 (8318)', function () {
assert.strictEqual(VARIANT_PORT_BASE, CLIPROXY_DEFAULT_PORT + 1);
assert.strictEqual(VARIANT_PORT_BASE, 8318);
});
it('VARIANT_PORT_MAX_OFFSET equals 100 (ports 8318-8417)', function () {
assert.strictEqual(VARIANT_PORT_MAX_OFFSET, 100);
});
});
describe('getNextAvailablePort - Basic Allocation', function () {
it('returns VARIANT_PORT_BASE (8318) when no variants exist', function () {
const port = getNextAvailablePort();
assert.strictEqual(port, VARIANT_PORT_BASE);
assert.strictEqual(port, 8318);
});
it('returns next available port when some ports used', function () {
// Create variant on first port
const settingsPath = path.join(testHome, '.ccs', 'test1.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy('test1', 'gemini', settingsPath, undefined, 8318);
const port = getNextAvailablePort();
assert.strictEqual(port, 8319);
});
it('skips used ports and returns first gap', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create variants on ports 8318 and 8320 (leaving 8319 as gap)
const settingsPath1 = path.join(ccsDir, 'test1.settings.json');
const settingsPath2 = path.join(ccsDir, 'test2.settings.json');
fs.writeFileSync(settingsPath1, JSON.stringify({ env: {} }));
fs.writeFileSync(settingsPath2, JSON.stringify({ env: {} }));
saveVariantLegacy('test1', 'gemini', settingsPath1, undefined, 8318);
saveVariantLegacy('test2', 'gemini', settingsPath2, undefined, 8320);
// Should return 8319 (the gap)
const port = getNextAvailablePort();
assert.strictEqual(port, 8319);
});
it('allocates sequential ports for multiple variants', function () {
const ccsDir = path.join(testHome, '.ccs');
for (let i = 0; i < 5; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
const port = getNextAvailablePort();
assert.strictEqual(port, 8318 + i);
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
}
});
});
describe('getNextAvailablePort - Boundary Conditions', function () {
it('returns last port (8417) when 99 ports used', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create 99 variants (ports 8318-8416)
for (let i = 0; i < 99; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
}
const port = getNextAvailablePort();
assert.strictEqual(port, 8417); // Last available port
});
it('throws when all 100 ports exhausted', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create 100 variants (ports 8318-8417)
for (let i = 0; i < 100; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
}
assert.throws(
() => getNextAvailablePort(),
/Port limit reached/,
'Should throw error when all ports exhausted'
);
});
it('error message includes variant count and recovery hint', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create 100 variants
for (let i = 0; i < 100; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
}
try {
getNextAvailablePort();
assert.fail('Should have thrown error');
} catch (err) {
assert.ok(err.message.includes('100/100'), 'Should include variant count');
assert.ok(
err.message.includes('ccs cliproxy remove'),
'Should include recovery hint'
);
}
});
});
describe('getNextAvailablePort - Port Reuse', function () {
it('reuses port after variant deletion', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create variant on port 8318
const settingsPath = path.join(ccsDir, 'test.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
// Next port should be 8319
let nextPort = getNextAvailablePort();
assert.strictEqual(nextPort, 8319);
// Remove the variant
removeVariantFromLegacyConfig('test');
// Now 8318 should be available again
nextPort = getNextAvailablePort();
assert.strictEqual(nextPort, 8318);
});
it('allocates lowest available port (not most recently freed)', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create 3 variants on ports 8318, 8319, 8320
for (let i = 0; i < 3; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, 8318 + i);
}
// Remove middle variant (port 8319)
removeVariantFromLegacyConfig('variant1');
// Next allocation should use 8319 (the gap), not 8321
const nextPort = getNextAvailablePort();
assert.strictEqual(nextPort, 8319);
});
});
describe('getNextAvailablePort - Legacy Variant Handling', function () {
it('ignores variants without port field in usage calculation', function () {
// Create variant without port (legacy format)
const config = {
profiles: {},
cliproxy: {
legacy_variant: {
provider: 'gemini',
settings: '/path/to/settings.json',
// No port field
},
},
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Should return first port since legacy variant has no port
const port = getNextAvailablePort();
assert.strictEqual(port, 8318);
});
it('handles mixed legacy and modern variants', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create legacy variant without port
const config = {
profiles: {},
cliproxy: {
legacy: {
provider: 'gemini',
settings: path.join(ccsDir, 'legacy.settings.json'),
// No port field
},
modern: {
provider: 'gemini',
settings: path.join(ccsDir, 'modern.settings.json'),
port: 8318,
},
},
};
fs.writeFileSync(configPath, JSON.stringify(config));
// Should skip 8318 (used by modern) and return 8319
const port = getNextAvailablePort();
assert.strictEqual(port, 8319);
});
});
describe('listVariantsFromConfig', function () {
it('returns empty object when no variants exist', function () {
const variants = listVariantsFromConfig();
assert.deepStrictEqual(variants, {});
});
it('includes port field for each variant', function () {
const ccsDir = path.join(testHome, '.ccs');
const settingsPath = path.join(ccsDir, 'test.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy('test', 'gemini', settingsPath, undefined, 8318);
const variants = listVariantsFromConfig();
assert.strictEqual(variants.test.port, 8318);
assert.strictEqual(variants.test.provider, 'gemini');
});
it('returns undefined port for legacy variants', function () {
// Create legacy variant without port
const config = {
profiles: {},
cliproxy: {
legacy: {
provider: 'gemini',
settings: '/path/to/settings.json',
// No port field
},
},
};
fs.writeFileSync(configPath, JSON.stringify(config));
const variants = listVariantsFromConfig();
assert.strictEqual(variants.legacy.port, undefined);
assert.strictEqual(variants.legacy.provider, 'gemini');
});
});
});
@@ -0,0 +1,501 @@
/**
* Variant Port Edge Case Tests
*
* Tests for edge cases and error handling in variant port isolation.
* Covers port exhaustion, race conditions, stale session cleanup,
* legacy migration, permission errors, and config corruption.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Set test isolation environment before importing
const testHome = path.join(
os.tmpdir(),
`ccs-test-edge-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
process.env.CCS_HOME = testHome;
const {
getNextAvailablePort,
VARIANT_PORT_BASE,
VARIANT_PORT_MAX_OFFSET,
listVariantsFromConfig,
saveVariantLegacy,
removeVariantFromLegacyConfig,
} = require('../../../dist/cliproxy/services/variant-config-adapter');
const {
getExistingProxy,
registerSession,
unregisterSession,
cleanupOrphanedSessions,
deleteSessionLockForPort,
getProxyStatus,
} = require('../../../dist/cliproxy/session-tracker');
const {
deleteConfigForPort,
configExists,
generateConfig,
} = require('../../../dist/cliproxy/config-generator');
describe('Variant Port Edge Cases', function () {
let configPath;
let cliproxyDir;
beforeEach(function () {
// Create test directories
const ccsDir = path.join(testHome, '.ccs');
cliproxyDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
configPath = path.join(ccsDir, 'config.json');
// Start with empty config
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }));
});
afterEach(function () {
// Clean up config and session files
try {
if (fs.existsSync(configPath)) {
fs.unlinkSync(configPath);
}
const files = fs.readdirSync(cliproxyDir);
for (const file of files) {
fs.unlinkSync(path.join(cliproxyDir, file));
}
} catch {
// Ignore cleanup errors
}
});
afterAll(function () {
// Clean up test directory
try {
fs.rmSync(testHome, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
delete process.env.CCS_HOME;
});
describe('Port Exhaustion', function () {
it('throws after 100 variants created', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create 100 variants
for (let i = 0; i < 100; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
}
assert.throws(() => getNextAvailablePort(), /Port limit reached/);
});
it('error message shows 100/100 and recovery hint', function () {
const ccsDir = path.join(testHome, '.ccs');
for (let i = 0; i < 100; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, VARIANT_PORT_BASE + i);
}
try {
getNextAvailablePort();
assert.fail('Should have thrown');
} catch (err) {
assert.ok(err.message.includes('100/100'));
assert.ok(err.message.includes('ccs cliproxy remove'));
}
});
it('frees port after variant removal', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create variant on port 8318
const settingsPath = path.join(ccsDir, 'test.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy('test', 'gemini', settingsPath, undefined, VARIANT_PORT_BASE);
// Next should be 8319
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE + 1);
// Remove variant
removeVariantFromLegacyConfig('test');
// 8318 should be free again
assert.strictEqual(getNextAvailablePort(), VARIANT_PORT_BASE);
});
});
describe('Stale Session Cleanup', function () {
it('cleans orphaned session lock on variant delete', function () {
const port = 8318;
// Create session file
registerSession(port, process.pid);
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
assert.ok(fs.existsSync(sessionPath));
// Simulate variant delete cleanup
deleteSessionLockForPort(port);
assert.strictEqual(fs.existsSync(sessionPath), false);
});
it('cleans stale lock when PID not running', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Create lock with dead PID
fs.writeFileSync(
sessionPath,
JSON.stringify({
port,
pid: 999999999, // Dead PID
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
// getExistingProxy should clean it up
const lock = getExistingProxy(port);
assert.strictEqual(lock, null);
assert.strictEqual(fs.existsSync(sessionPath), false);
});
});
describe('Legacy Variant Migration', function () {
it('variant without port gets undefined in listing', function () {
// Create legacy variant without port
const config = {
profiles: {},
cliproxy: {
legacy: {
provider: 'gemini',
settings: '/path/to/settings.json',
// No port field
},
},
};
fs.writeFileSync(configPath, JSON.stringify(config));
const variants = listVariantsFromConfig();
assert.strictEqual(variants.legacy.port, undefined);
});
it('legacy variant does not block port allocation', function () {
// Create legacy variant without port
const config = {
profiles: {},
cliproxy: {
legacy: {
provider: 'gemini',
settings: '/path/to/settings.json',
// No port field - doesn't count toward port usage
},
},
};
fs.writeFileSync(configPath, JSON.stringify(config));
// First port should still be available
const port = getNextAvailablePort();
assert.strictEqual(port, VARIANT_PORT_BASE);
});
});
describe('Config Corruption', function () {
it('handles malformed sessions-{port}.json gracefully', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Write invalid JSON
fs.writeFileSync(sessionPath, '{ invalid json }');
// getExistingProxy should return null, not throw
const lock = getExistingProxy(port);
assert.strictEqual(lock, null);
});
it('handles missing config-{port}.yaml gracefully', function () {
const port = 8318;
// configExists should return false, not throw
assert.strictEqual(configExists(port), false);
});
it('handles sessions file with missing required fields', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Write JSON missing required fields
fs.writeFileSync(sessionPath, JSON.stringify({ port: 8318 }));
// getExistingProxy should return null (invalid structure)
const lock = getExistingProxy(port);
assert.strictEqual(lock, null);
});
it('handles sessions file with wrong data types', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Write JSON with wrong types
fs.writeFileSync(
sessionPath,
JSON.stringify({
port: 'not-a-number',
pid: 'also-not-a-number',
sessions: 'not-an-array',
})
);
const lock = getExistingProxy(port);
assert.strictEqual(lock, null);
});
});
describe('Cleanup on Crash', function () {
it('getExistingProxy cleans stale lock if PID dead', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Create lock with dead PID (simulating crashed proxy)
fs.writeFileSync(
sessionPath,
JSON.stringify({
port,
pid: 999999999,
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
const lock = getExistingProxy(port);
assert.strictEqual(lock, null);
assert.strictEqual(fs.existsSync(sessionPath), false);
});
it('cleanupOrphanedSessions removes stale lock', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Create lock with dead PID
fs.writeFileSync(
sessionPath,
JSON.stringify({
port,
pid: 999999999,
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
cleanupOrphanedSessions(port);
assert.strictEqual(fs.existsSync(sessionPath), false);
});
it('variant delete cleans session lock even if proxy crashed', function () {
const port = 8318;
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
// Create lock with dead PID
fs.writeFileSync(
sessionPath,
JSON.stringify({
port,
pid: 999999999,
sessions: ['session1'],
startedAt: new Date().toISOString(),
})
);
// deleteSessionLockForPort is called during variant removal
deleteSessionLockForPort(port);
assert.strictEqual(fs.existsSync(sessionPath), false);
});
});
describe('Variant Lifecycle Integration', function () {
it('creates variant with unique port and separate files', function () {
const ccsDir = path.join(testHome, '.ccs');
const port = getNextAvailablePort();
// Create variant
const settingsPath = path.join(ccsDir, 'test.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
// Generate config
generateConfig('gemini', port);
// Verify files exist
assert.ok(configExists(port));
// Start session
const sessionId = registerSession(port, process.pid);
const status = getProxyStatus(port);
assert.strictEqual(status.running, true);
assert.strictEqual(status.port, port);
// Clean up session
unregisterSession(sessionId, port);
});
it('removes variant and cleans up all port files', function () {
const ccsDir = path.join(testHome, '.ccs');
const port = 8318;
// Create variant
const settingsPath = path.join(ccsDir, 'test.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy('test', 'gemini', settingsPath, undefined, port);
// Generate config and session
generateConfig('gemini', port);
registerSession(port, process.pid);
// Verify files exist
assert.ok(configExists(port));
assert.strictEqual(getProxyStatus(port).running, true);
// Remove variant (simulating full cleanup)
removeVariantFromLegacyConfig('test');
deleteConfigForPort(port);
deleteSessionLockForPort(port);
// Verify cleanup
assert.strictEqual(configExists(port), false);
assert.strictEqual(getProxyStatus(port).running, false);
});
it('port reuse after deletion does not have stale data', function () {
const ccsDir = path.join(testHome, '.ccs');
// Create variant A
const settingsA = path.join(ccsDir, 'variantA.settings.json');
fs.writeFileSync(settingsA, JSON.stringify({ env: { KEY: 'A' } }));
const portA = getNextAvailablePort();
saveVariantLegacy('variantA', 'gemini', settingsA, undefined, portA);
generateConfig('gemini', portA);
registerSession(portA, process.pid);
// Remove variant A with full cleanup
removeVariantFromLegacyConfig('variantA');
deleteConfigForPort(portA);
deleteSessionLockForPort(portA);
// Create variant B - should get same port
const settingsB = path.join(ccsDir, 'variantB.settings.json');
fs.writeFileSync(settingsB, JSON.stringify({ env: { KEY: 'B' } }));
const portB = getNextAvailablePort();
assert.strictEqual(portB, portA); // Port should be reused
// New session should start fresh
saveVariantLegacy('variantB', 'gemini', settingsB, undefined, portB);
generateConfig('gemini', portB);
const sessionB = registerSession(portB, process.pid);
const status = getProxyStatus(portB);
assert.strictEqual(status.running, true);
assert.strictEqual(status.sessionCount, 1);
// Clean up
unregisterSession(sessionB, portB);
});
});
describe('Multiple Concurrent Variants', function () {
it('creates 3 variants with different ports', function () {
const ccsDir = path.join(testHome, '.ccs');
const ports = [];
for (let i = 0; i < 3; i++) {
const port = getNextAvailablePort();
ports.push(port);
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, port);
}
// Verify all ports are different
const uniquePorts = new Set(ports);
assert.strictEqual(uniquePorts.size, 3);
// Verify sequential assignment
assert.strictEqual(ports[0], VARIANT_PORT_BASE);
assert.strictEqual(ports[1], VARIANT_PORT_BASE + 1);
assert.strictEqual(ports[2], VARIANT_PORT_BASE + 2);
});
it('each has separate config file', function () {
const ccsDir = path.join(testHome, '.ccs');
const ports = [8318, 8319, 8320];
for (let i = 0; i < 3; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
generateConfig('gemini', ports[i]);
}
// Verify separate config files
for (const port of ports) {
assert.ok(configExists(port), `Config for port ${port} should exist`);
}
});
it('each has separate sessions file when running', function () {
const ports = [8318, 8319, 8320];
for (const port of ports) {
registerSession(port, process.pid);
}
// Verify separate session files
for (const port of ports) {
const sessionPath = path.join(cliproxyDir, `sessions-${port}.json`);
assert.ok(fs.existsSync(sessionPath), `Session file for port ${port} should exist`);
}
// Clean up
for (const port of ports) {
deleteSessionLockForPort(port);
}
});
it('removing one does not affect others', function () {
const ccsDir = path.join(testHome, '.ccs');
const ports = [8318, 8319, 8320];
// Create 3 variants
for (let i = 0; i < 3; i++) {
const settingsPath = path.join(ccsDir, `variant${i}.settings.json`);
fs.writeFileSync(settingsPath, JSON.stringify({ env: {} }));
saveVariantLegacy(`variant${i}`, 'gemini', settingsPath, undefined, ports[i]);
generateConfig('gemini', ports[i]);
registerSession(ports[i], process.pid);
}
// Remove middle variant
removeVariantFromLegacyConfig('variant1');
deleteConfigForPort(ports[1]);
deleteSessionLockForPort(ports[1]);
// Verify others still exist
assert.ok(configExists(ports[0]));
assert.ok(!configExists(ports[1])); // Removed
assert.ok(configExists(ports[2]));
assert.strictEqual(getProxyStatus(ports[0]).running, true);
assert.strictEqual(getProxyStatus(ports[1]).running, false); // Removed
assert.strictEqual(getProxyStatus(ports[2]).running, true);
// Clean up remaining
deleteSessionLockForPort(ports[0]);
deleteSessionLockForPort(ports[2]);
});
});
});
@@ -31,7 +31,9 @@ export function ProviderEditor({
authStatus,
catalog,
logoProvider,
baseProvider,
isRemoteMode,
port,
onAddAccount,
onSetDefault,
onRemoveAccount,
@@ -46,6 +48,9 @@ export function ProviderEditor({
const deletePresetMutation = useDeletePreset();
const savedPresets = presetsData?.presets || [];
// Use baseProvider for model filtering (for variants, this is the parent provider)
const modelFilterProvider = baseProvider || provider;
const providerModels = useMemo(() => {
if (!modelsData?.models) return [];
const ownerMap: Record<string, string[]> = {
@@ -57,11 +62,13 @@ export function ProviderEditor({
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
};
const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()];
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
modelFilterProvider.toLowerCase(),
];
return modelsData.models.filter((m) =>
owners.some((o) => m.owned_by.toLowerCase().includes(o))
);
}, [modelsData, provider]);
}, [modelsData, modelFilterProvider]);
const {
data,
@@ -128,6 +135,7 @@ export function ProviderEditor({
isRawJsonValid={isRawJsonValid}
isSaving={saveMutation.isPending}
isRemoteMode={isRemoteMode}
port={port}
onRefetch={refetch}
onSave={() => saveMutation.mutate()}
/>
@@ -37,7 +37,7 @@ export function ModelConfigSection({
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
<div className="flex flex-wrap gap-2">
{/* Recommended presets from catalog */}
{catalog?.models.slice(0, 3).map((model) => (
{catalog?.models.slice(0, 4).map((model) => (
<Button
key={model.id}
variant="outline"
@@ -5,7 +5,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Save, Loader2, RefreshCw, Globe } from 'lucide-react';
import { Save, Loader2, RefreshCw, Globe, Network } from 'lucide-react';
import { ProviderLogo } from '../provider-logo';
import type { SettingsResponse } from './types';
@@ -19,6 +19,7 @@ interface ProviderEditorHeaderProps {
isRawJsonValid: boolean;
isSaving: boolean;
isRemoteMode?: boolean;
port?: number;
onRefetch: () => void;
onSave: () => void;
}
@@ -33,6 +34,7 @@ export function ProviderEditorHeader({
isRawJsonValid,
isSaving,
isRemoteMode,
port,
onRefetch,
onSave,
}: ProviderEditorHeaderProps) {
@@ -52,6 +54,11 @@ export function ProviderEditorHeader({
Remote
</Badge>
)}
{port && (
<Badge variant="outline" className="text-xs gap-1 font-mono">
<Network className="w-3 h-3" />:{port}
</Badge>
)}
{!isRemoteMode && data?.path && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*[\\/]/, '')}
@@ -21,8 +21,12 @@ export interface ProviderEditorProps {
catalog?: ProviderCatalog;
/** Provider type for logo display (defaults to provider) */
logoProvider?: string;
/** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */
baseProvider?: string;
/** True if using remote CLIProxy mode (hides local paths) */
isRemoteMode?: boolean;
/** Port number for variant (for display in header) */
port?: number;
onAddAccount: () => void;
onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void;
+2
View File
@@ -50,6 +50,8 @@ export interface Variant {
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
settings: string;
account?: string;
port?: number;
model?: string;
}
export interface CreateVariant {
+2
View File
@@ -339,7 +339,9 @@ export function CliproxyPage() {
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
baseProvider={selectedVariantData.provider}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider,