mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user