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:
kaitranntt
2026-01-28 13:58:16 -05:00
parent 4124780ce0
commit bbad73b554
4 changed files with 67 additions and 20 deletions
+9 -1
View File
@@ -149,7 +149,15 @@ export async function stopAutoSyncWatcher(): Promise<void> {
}
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;
log('Watcher stopped');
}
+20 -5
View File
@@ -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
const claudeApiKeys = payload.map(transformToConfigFormat);
@@ -198,10 +211,12 @@ export function getLocalSyncStatus(): {
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf8');
const config = yaml.load(content) as Record<string, unknown>;
const keys = config['claude-api-key'];
if (Array.isArray(keys)) {
currentKeyCount = keys.length;
const config = yaml.load(content) as Record<string, unknown> | null;
if (config && typeof config === 'object') {
const keys = config['claude-api-key'];
if (Array.isArray(keys)) {
currentKeyCount = keys.length;
}
}
} catch {
// Ignore parse errors
+11
View File
@@ -104,6 +104,17 @@ export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | nul
if (!sanitizedName || sanitizedName === '') {
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 claudeKey: ClaudeKey = {
+27 -14
View File
@@ -89,23 +89,36 @@ async function fetchSyncPreview(): Promise<SyncPreview> {
* Execute sync to remote CLIProxy
*/
async function executeSync(): Promise<SyncResult> {
const response = await fetch('/api/cliproxy/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
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)
try {
const response = await fetch('/api/cliproxy/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
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);
}
}
/**