From 2b4d21e8ae615c840d76007d733017d375e6036f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 24 Dec 2025 22:12:09 -0500 Subject: [PATCH] fix(cliproxy): preserve user API keys during config regeneration When CCS updates trigger CLIProxy config regeneration, user-added API keys in ~/.ccs/cliproxy/config.yaml were being overwritten with only the internal CCS key. Changes: - Add parseUserApiKeys() to extract user keys from existing config - Modify regenerateConfig() to preserve user API keys alongside port - Update generateUnifiedConfigContent() to accept userApiKeys param - Add comprehensive test suite (14 tests) for key preservation Fixes #200 --- src/cliproxy/config-generator.ts | 84 ++++- src/cliproxy/index.ts | 1 + tests/unit/cliproxy/config-generator.test.js | 359 +++++++++++++++++++ 3 files changed, 435 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index c9d44c94..f54d28b8 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -146,8 +146,14 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { * Generate UNIFIED config.yaml content for ALL providers * This enables concurrent usage of gemini/codex/agy without config conflicts. * CLIProxyAPI routes requests by model name to the appropriate provider. + * + * @param port - Server port (default: 8317) + * @param userApiKeys - User-added API keys to preserve (default: []) */ -function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): string { +function generateUnifiedConfigContent( + port: number = CLIPROXY_DEFAULT_PORT, + userApiKeys: string[] = [] +): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories // Convert Windows backslashes to forward slashes for YAML compatibility const authDirNormalized = authDir.split(path.sep).join('/'); @@ -155,6 +161,10 @@ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): str // Get logging settings from user config (disabled by default) const { loggingToFile, requestLog } = getLoggingSettings(); + // Build api-keys section with internal key + preserved user keys + const allApiKeys = [CCS_INTERNAL_API_KEY, ...userApiKeys]; + const apiKeysYaml = allApiKeys.map((key) => ` - "${key}"`).join('\n'); + // Unified config with enhanced CLIProxyAPI features const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} # Supports: gemini, codex, agy, qwen, iflow (concurrent usage) @@ -219,9 +229,10 @@ quota-exceeded: # Authentication # ============================================================================= -# API keys for CCS internal requests +# API keys for CCS and user-added external requests +# NOTE: User-added keys are preserved across CCS updates (fix for issue #200) api-keys: - - "${CCS_INTERNAL_API_KEY}" +${apiKeysYaml} # OAuth tokens directory (auto-discovered by CLIProxyAPI) auth-dir: "${authDirNormalized}" @@ -256,24 +267,79 @@ export function generateConfig( } /** - * Force regenerate config.yaml with latest settings - * Deletes existing config and creates fresh one with current port + * Parse user-added API keys from existing config content. + * Extracts all keys except the internal CCS key for preservation. + * + * @param content - Existing config.yaml content + * @returns Array of user-added API keys (excludes CCS_INTERNAL_API_KEY) + */ +export function parseUserApiKeys(content: string): string[] { + const userKeys: string[] = []; + + // Find the api-keys section by looking for lines starting with " - " after "api-keys:" + // Normalize line endings first + const normalizedContent = content.replace(/\r\n/g, '\n'); + + // Find the api-keys: line and extract all subsequent key entries + const lines = normalizedContent.split('\n'); + let inApiKeysSection = false; + + for (const line of lines) { + // Check if this is the start of api-keys section + if (line.match(/^api-keys:\s*$/)) { + inApiKeysSection = true; + continue; + } + + // If we're in the api-keys section, look for key entries + if (inApiKeysSection) { + // Key entries are indented with " - " or similar + const keyMatch = line.match(/^\s+-\s*"([^"]*)"/); + if (keyMatch) { + const key = keyMatch[1]; + // Exclude the internal CCS key and empty strings + if (key && key !== CCS_INTERNAL_API_KEY) { + userKeys.push(key); + } + } else if (line.match(/^\S/) && line.trim().length > 0) { + // Non-indented line that's not empty means we've left the api-keys section + break; + } + // Continue for blank lines or other indented content + } + } + + return userKeys; +} + +/** + * Force regenerate config.yaml with latest settings. + * Preserves user-added API keys and port settings. + * + * @param port - Default port to use if not found in existing config * @returns Path to new config file */ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { const configPath = getConfigPath(); - // Read existing port if config exists (preserve user's port choice) + // Preserve user settings from existing config let effectivePort = port; + let userApiKeys: string[] = []; + if (fs.existsSync(configPath)) { try { const content = fs.readFileSync(configPath, 'utf-8'); + + // Preserve port setting const portMatch = content.match(/^port:\s*(\d+)/m); if (portMatch) { effectivePort = parseInt(portMatch[1], 10); } + + // Preserve user-added API keys (fix for issue #200) + userApiKeys = parseUserApiKeys(content); } catch { - // Use default port if reading fails + // Use defaults if reading fails } // Delete existing config fs.unlinkSync(configPath); @@ -283,8 +349,8 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.mkdirSync(getAuthDir(), { recursive: true, mode: 0o700 }); - // Generate fresh config - const configContent = generateUnifiedConfigContent(effectivePort); + // Generate fresh config with preserved user API keys + const configContent = generateUnifiedConfigContent(effectivePort, userApiKeys); fs.writeFileSync(configPath, configContent, { mode: 0o600 }); return configPath; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index e42705be..6381dc93 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -55,6 +55,7 @@ export { generateConfig, regenerateConfig, configNeedsRegeneration, + parseUserApiKeys, getClaudeEnvVars, getEffectiveEnvVars, getProviderSettingsPath, diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 91dd4b0e..eab0743d 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -166,6 +166,365 @@ describe('Config Generator', () => { }); }); + // ========================================================================= + // API Key Preservation Tests (Issue #200) + // ========================================================================= + describe('parseUserApiKeys', () => { + let parseUserApiKeys; + + beforeEach(() => { + // Clear cache and reload module + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + parseUserApiKeys = configGenerator.parseUserApiKeys; + }); + + it('extracts user-added API keys from config content', () => { + const configContent = ` +# CLIProxyAPI config generated by CCS v4 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "user-custom-key-12345" + - "another-user-key-abcde" + +auth-dir: "/home/user/.ccs/cliproxy/auth" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-custom-key-12345', 'another-user-key-abcde']); + }); + + it('excludes the internal CCS key', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "my-custom-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.strictEqual(userKeys.length, 1); + assert.strictEqual(userKeys[0], 'my-custom-key'); + assert(!userKeys.includes('ccs-internal-managed'), 'Should not include internal key'); + }); + + it('returns empty array when only internal key exists', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, []); + }); + + it('returns empty array when no api-keys section exists', () => { + const configContent = ` +port: 8317 +debug: false +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, []); + }); + + it('handles single user key', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "single-user-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['single-user-key']); + }); + + it('handles multiple user keys', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "key1" + - "key2" + - "key3" + - "key4" + - "key5" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['key1', 'key2', 'key3', 'key4', 'key5']); + }); + + it('handles keys with special characters', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "sk_live_1234567890abcdef" + - "api-key-with-dashes" + - "key_with_underscores_123" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, [ + 'sk_live_1234567890abcdef', + 'api-key-with-dashes', + 'key_with_underscores_123', + ]); + }); + + it('preserves key order', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "zebra-key" + - "alpha-key" + - "middle-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['zebra-key', 'alpha-key', 'middle-key']); + }); + + it('handles empty string key (edge case)', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "" + - "valid-key" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + // Empty strings should be filtered out (only truthy values) + assert.deepStrictEqual(userKeys, ['valid-key']); + }); + + it('handles config with Windows line endings', () => { + const configContent = + 'api-keys:\r\n - "ccs-internal-managed"\r\n - "user-key"\r\n\r\nauth-dir: "/test"\r\n'; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key']); + }); + + it('handles different indentation styles', () => { + const configContent = ` +api-keys: + - "ccs-internal-managed" + - "user-key-with-4-spaces" + +auth-dir: "/test" +`; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key-with-4-spaces']); + }); + + it('handles tabs in indentation', () => { + const configContent = 'api-keys:\n\t- "ccs-internal-managed"\n\t- "user-key-with-tab"\n\nauth-dir: "/test"'; + + const userKeys = parseUserApiKeys(configContent); + + assert.deepStrictEqual(userKeys, ['user-key-with-tab']); + }); + }); + + describe('regenerateConfig API key preservation', () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + + let testDir; + let originalCcsHome; + let regenerateConfig; + let getConfigPath; + + beforeEach(() => { + // Create a temporary test directory + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; + + // Clear cache and reload module + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + delete require.cache[require.resolve('../../../dist/utils/config-manager')]; + const configGenerator = require('../../../dist/cliproxy/config-generator'); + regenerateConfig = configGenerator.regenerateConfig; + getConfigPath = configGenerator.getConfigPath; + }); + + afterEach(() => { + // Restore environment + process.env.CCS_HOME = originalCcsHome; + + // Clean up test directory + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('preserves user API keys during regeneration', () => { + // Create initial config with user keys + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "user-custom-key-12345" + - "another-user-key" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config (simulating CCS update) + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify user keys are preserved + assert(newConfig.includes('user-custom-key-12345'), 'Should preserve first user key'); + assert(newConfig.includes('another-user-key'), 'Should preserve second user key'); + assert(newConfig.includes('ccs-internal-managed'), 'Should include internal key'); + }); + + it('preserves port setting during regeneration', () => { + // Create initial config with custom port + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 9999 + +api-keys: + - "ccs-internal-managed" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify port is preserved + assert(newConfig.includes('port: 9999'), 'Should preserve custom port'); + }); + + it('creates fresh config when none exists', () => { + // Ensure clean state + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + const configPath = path.join(cliproxyDir, 'config.yaml'); + + assert(!fs.existsSync(configPath), 'Config should not exist initially'); + + // Generate config + regenerateConfig(); + + // Verify config was created + assert(fs.existsSync(configPath), 'Config should be created'); + + const config = fs.readFileSync(configPath, 'utf-8'); + assert(config.includes('ccs-internal-managed'), 'Should include internal key'); + assert(config.includes('port: 8317'), 'Should use default port'); + }); + + it('handles corrupted config gracefully', () => { + // Create corrupted config + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), 'invalid yaml content: [[['); + + // Should not throw + assert.doesNotThrow(() => regenerateConfig()); + + // Should create valid config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config'); + }); + + it('handles empty config file gracefully', () => { + // Create empty config + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), ''); + + // Should not throw + assert.doesNotThrow(() => regenerateConfig()); + + // Should create valid config with defaults + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert(newConfig.includes('ccs-internal-managed'), 'Should create valid config'); + assert(newConfig.includes('port: 8317'), 'Should use default port'); + }); + + it('preserves multiple user keys in correct order', () => { + // Create config with multiple user keys + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v3 +port: 8317 + +api-keys: + - "ccs-internal-managed" + - "key-first" + - "key-second" + - "key-third" + +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + // Regenerate config + regenerateConfig(); + + // Read regenerated config + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + // Verify order is preserved (first > second > third) + const firstPos = newConfig.indexOf('key-first'); + const secondPos = newConfig.indexOf('key-second'); + const thirdPos = newConfig.indexOf('key-third'); + + assert(firstPos < secondPos, 'First key should come before second'); + assert(secondPos < thirdPos, 'Second key should come before third'); + }); + }); + describe('Cross-platform path handling', () => { it('normalizes paths regardless of current platform', () => { // The fix should work consistently on Windows, macOS, and Linux