From 313e244302c174fb9fa84706d6e1ab0003bf2fc5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 10:58:42 -0400 Subject: [PATCH] fix(cliproxy): harden corrupted registry recovery --- src/cliproxy/accounts/registry.ts | 85 ++++++++++++++++--- .../account-registry-integrity.test.ts | 80 +++++++++++++++++ 2 files changed, 152 insertions(+), 13 deletions(-) diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index ad796a58..0f94ffac 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -63,19 +63,47 @@ function inferEmailFromTokenFileName(tokenFile: string): string | undefined { return match?.[1]; } +interface RegistryPopulationIssue { + tokenFile: string; + paused: boolean; + reason: string; +} + +function describeRegistryPopulationIssue(issue: RegistryPopulationIssue): string { + const sourceDir = issue.paused ? 'auth-paused' : 'auth'; + return `${sourceDir}/${issue.tokenFile} (${issue.reason})`; +} + +function getRegistryPopulationIssueReason(error: unknown): string { + if (error instanceof SyntaxError) { + return 'invalid JSON'; + } + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + return 'unreadable token file'; +} + function populateRegistryFromTokenFiles( registry: AccountsRegistry, options: { includePaused?: boolean } = {} -): void { +): RegistryPopulationIssue[] { + const issues: RegistryPopulationIssue[] = []; + for (const token of listRecoverableTokenFiles(options)) { try { const content = fs.readFileSync(token.filePath, 'utf-8'); const data = JSON.parse(content) as { - type?: string; - email?: string; - project_id?: string; + type?: unknown; + email?: unknown; + project_id?: unknown; }; - if (!data.type) { + if (typeof data.type !== 'string' || !data.type.trim()) { + issues.push({ + tokenFile: token.tokenFile, + paused: token.paused, + reason: 'invalid token type', + }); continue; } @@ -139,27 +167,58 @@ function populateRegistryFromTokenFiles( } providerAccounts.accounts[accountId] = accountMeta; - } catch { + } catch (error) { + issues.push({ + tokenFile: token.tokenFile, + paused: token.paused, + reason: getRegistryPopulationIssueReason(error), + }); continue; } } + + return issues; } -function backupCorruptedAccountsRegistry(registryPath: string): void { - if (!fs.existsSync(registryPath)) { - return; +function getCorruptedRegistryBackupPath(registryPath: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupBasePath = `${registryPath}.corrupt-${timestamp}`; + let backupPath = `${backupBasePath}.bak`; + let suffix = 1; + + while (fs.existsSync(backupPath)) { + backupPath = `${backupBasePath}-${suffix}.bak`; + suffix++; } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = `${registryPath}.corrupt-${timestamp}.bak`; + return backupPath; +} + +function backupCorruptedAccountsRegistry(registryPath: string): string | null { + if (!fs.existsSync(registryPath)) { + return null; + } + + const backupPath = getCorruptedRegistryBackupPath(registryPath); fs.renameSync(registryPath, backupPath); + return backupPath; } function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRegistry { - backupCorruptedAccountsRegistry(registryPath); + const backupPath = backupCorruptedAccountsRegistry(registryPath); const recovered = createDefaultRegistry(); - populateRegistryFromTokenFiles(recovered, { includePaused: true }); + const issues = populateRegistryFromTokenFiles(recovered, { includePaused: true }); writeAccountsRegistryToDisk(recovered); + + if (issues.length > 0) { + const recoveredFrom = backupPath || registryPath; + console.error( + `[!] Recovered corrupted account registry from ${recoveredFrom}, but skipped ${issues.length} token file(s): ${issues + .map(describeRegistryPopulationIssue) + .join(', ')}` + ); + } + return recovered; } diff --git a/tests/unit/cliproxy/account-registry-integrity.test.ts b/tests/unit/cliproxy/account-registry-integrity.test.ts index 42cb57e3..ff5becd2 100644 --- a/tests/unit/cliproxy/account-registry-integrity.test.ts +++ b/tests/unit/cliproxy/account-registry-integrity.test.ts @@ -21,6 +21,24 @@ async function loadAccountManager() { return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`); } +async function captureConsoleError(fn: () => Promise | T): Promise<{ result: T; messages: string[] }> { + const originalError = console.error; + const messages: string[] = []; + + console.error = ((...args: unknown[]) => { + messages.push(args.map(String).join(' ')); + }) as typeof console.error; + + try { + return { + result: await fn(), + messages, + }; + } finally { + console.error = originalError; + } +} + describe('account registry integrity', () => { it('does not create accounts.json during no-op discovery', async () => { await withIsolatedHome(async (homeDir) => { @@ -147,6 +165,68 @@ describe('account registry integrity', () => { }); }); + it('surfaces skipped token files only during corruption recovery', async () => { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'codex-valid@example.com.json'), + JSON.stringify({ type: 'codex', email: 'valid@example.com' }), + 'utf8' + ); + fs.writeFileSync( + path.join(authDir, 'codex-invalid@example.com.json'), + JSON.stringify({ type: 123, email: 'invalid@example.com' }), + 'utf8' + ); + fs.writeFileSync(registryPath, '{"providers":', 'utf8'); + + const { getProviderAccounts } = await loadAccountManager(); + const { result: accounts, messages } = await captureConsoleError(() => + getProviderAccounts('codex') + ); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.id).toBe('valid@example.com'); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('Recovered corrupted account registry'); + expect(messages[0]).toContain('auth/codex-invalid@example.com.json'); + expect(messages[0]).toContain('invalid token type'); + }); + }); + + it('prefers active tokens over paused duplicates during corruption recovery', async () => { + await withIsolatedHome(async (homeDir) => { + const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const pausedDir = path.join(cliproxyDir, 'auth-paused'); + const registryPath = path.join(cliproxyDir, 'accounts.json'); + fs.mkdirSync(authDir, { recursive: true }); + fs.mkdirSync(pausedDir, { recursive: true }); + + fs.writeFileSync( + path.join(authDir, 'codex-shared@example.com.json'), + JSON.stringify({ type: 'codex', email: 'active@example.com' }), + 'utf8' + ); + fs.writeFileSync( + path.join(pausedDir, 'codex-shared@example.com.json'), + JSON.stringify({ type: 'codex', email: 'paused@example.com' }), + 'utf8' + ); + fs.writeFileSync(registryPath, '{"providers":', 'utf8'); + + const { getProviderAccounts } = await loadAccountManager(); + const accounts = getProviderAccounts('codex'); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.id).toBe('active@example.com'); + expect(accounts[0]?.tokenFile).toBe('codex-shared@example.com.json'); + expect(accounts[0]?.paused).toBeUndefined(); + }); + }); + it('does not repopulate paused tokens during normal startup discovery', async () => { await withIsolatedHome(async (homeDir) => { const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');