mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-09 16:23:58 +00:00
fix(cliproxy): harden sync against edge cases
Backend: - Handle config file deletion race condition with try-catch - Add null check for empty config files in yaml.load() - Limit profile names to 64 chars, require alphanumeric content - Add 5s timeout for watcher close() to prevent hangs Frontend: - Add 30s fetch timeout with AbortController for sync requests
This commit is contained in:
@@ -149,7 +149,15 @@ export async function stopAutoSyncWatcher(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (watcherInstance) {
|
if (watcherInstance) {
|
||||||
await watcherInstance.close();
|
const closePromise = watcherInstance.close();
|
||||||
|
const timeoutPromise = new Promise<void>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Close timeout')), 5000)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await Promise.race([closePromise, timeoutPromise]);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Warning: ${(err as Error).message}, forcing cleanup`);
|
||||||
|
}
|
||||||
watcherInstance = null;
|
watcherInstance = null;
|
||||||
log('Watcher stopped');
|
log('Watcher stopped');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,20 @@ export function syncToLocalConfig(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
let configContent: string;
|
||||||
|
try {
|
||||||
|
configContent = fs.readFileSync(configPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
syncedCount: 0,
|
||||||
|
configPath,
|
||||||
|
error: 'CLIProxy config deleted during sync. Run ccs doctor to regenerate.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Transform payload to config format
|
// Transform payload to config format
|
||||||
const claudeApiKeys = payload.map(transformToConfigFormat);
|
const claudeApiKeys = payload.map(transformToConfigFormat);
|
||||||
@@ -198,10 +211,12 @@ export function getLocalSyncStatus(): {
|
|||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(configPath, 'utf8');
|
const content = fs.readFileSync(configPath, 'utf8');
|
||||||
const config = yaml.load(content) as Record<string, unknown>;
|
const config = yaml.load(content) as Record<string, unknown> | null;
|
||||||
const keys = config['claude-api-key'];
|
if (config && typeof config === 'object') {
|
||||||
if (Array.isArray(keys)) {
|
const keys = config['claude-api-key'];
|
||||||
currentKeyCount = keys.length;
|
if (Array.isArray(keys)) {
|
||||||
|
currentKeyCount = keys.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | nul
|
|||||||
if (!sanitizedName || sanitizedName === '') {
|
if (!sanitizedName || sanitizedName === '') {
|
||||||
return null; // Skip profiles with invalid names
|
return null; // Skip profiles with invalid names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip if name is too long (>64 chars)
|
||||||
|
if (sanitizedName.length > 64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if name has no alphanumeric characters (e.g., only special chars -> "-----")
|
||||||
|
if (!/[a-zA-Z0-9]/.test(sanitizedName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = `${sanitizedName}-`;
|
const prefix = `${sanitizedName}-`;
|
||||||
|
|
||||||
const claudeKey: ClaudeKey = {
|
const claudeKey: ClaudeKey = {
|
||||||
|
|||||||
@@ -89,23 +89,36 @@ async function fetchSyncPreview(): Promise<SyncPreview> {
|
|||||||
* Execute sync to remote CLIProxy
|
* Execute sync to remote CLIProxy
|
||||||
*/
|
*/
|
||||||
async function executeSync(): Promise<SyncResult> {
|
async function executeSync(): Promise<SyncResult> {
|
||||||
const response = await fetch('/api/cliproxy/sync', {
|
const controller = new AbortController();
|
||||||
method: 'POST',
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
let message = 'Sync failed';
|
const response = await fetch('/api/cliproxy/sync', {
|
||||||
try {
|
method: 'POST',
|
||||||
const data = await response.json();
|
headers: { 'Content-Type': 'application/json' },
|
||||||
message = data.error || data.message || message;
|
signal: controller.signal,
|
||||||
} catch {
|
});
|
||||||
// Non-JSON response (e.g., 502 Bad Gateway)
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Sync failed';
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
message = data.error || data.message || message;
|
||||||
|
} catch {
|
||||||
|
// Non-JSON response (e.g., 502 Bad Gateway)
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Sync request timed out after 30 seconds');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user