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
This commit is contained in:
kaitranntt
2025-12-24 22:12:09 -05:00
parent 7dafed663b
commit 2b4d21e8ae
3 changed files with 435 additions and 9 deletions
+75 -9
View File
@@ -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;
+1
View File
@@ -55,6 +55,7 @@ export {
generateConfig,
regenerateConfig,
configNeedsRegeneration,
parseUserApiKeys,
getClaudeEnvVars,
getEffectiveEnvVars,
getProviderSettingsPath,
@@ -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