diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index 0c7228a1..c230c89e 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -21,6 +21,7 @@ import { moveTokenToPaused, moveTokenFromPaused, deleteTokenFile, + listRecoverableTokenFiles, } from './token-file-ops'; /** Default registry structure */ @@ -31,6 +32,218 @@ function createDefaultRegistry(): AccountsRegistry { }; } +function ensureProviderRegistry( + registry: AccountsRegistry, + provider: CLIProxyProvider +): NonNullable { + if (!registry.providers[provider]) { + registry.providers[provider] = { + default: 'default', + accounts: {}, + }; + } + + return registry.providers[provider] as NonNullable< + AccountsRegistry['providers'][CLIProxyProvider] + >; +} + +function resolveProviderFromTokenType(typeValue: string): CLIProxyProvider | undefined { + for (const [provider, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { + if (typeValues.includes(typeValue)) { + return provider as CLIProxyProvider; + } + } + + return undefined; +} + +const EMAIL_FILE_NAME_PATTERN = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i; + +function inferEmailFromTokenFileName( + tokenFile: string, + provider: CLIProxyProvider +): string | undefined { + const baseName = tokenFile.replace(/\.json$/i, ''); + const providerPrefix = `${provider}-`; + const candidate = baseName.startsWith(providerPrefix) + ? baseName.slice(providerPrefix.length) + : baseName; + + if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + const scopedCandidate = candidate.slice(candidate.indexOf('-') + 1); + if ( + scopedCandidate && + scopedCandidate !== candidate && + EMAIL_FILE_NAME_PATTERN.test(scopedCandidate) + ) { + return scopedCandidate; + } + } + + return EMAIL_FILE_NAME_PATTERN.test(candidate) ? candidate : undefined; +} + +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 } = {} +): 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?: unknown; + email?: unknown; + project_id?: unknown; + }; + if (typeof data.type !== 'string' || !data.type.trim()) { + issues.push({ + tokenFile: token.tokenFile, + paused: token.paused, + reason: 'invalid token type', + }); + continue; + } + + const provider = resolveProviderFromTokenType(data.type.toLowerCase()); + if (!provider) { + continue; + } + + const providerAccounts = ensureProviderRegistry(registry, provider); + const projectId = + typeof data.project_id === 'string' && data.project_id.trim() + ? data.project_id.trim() + : null; + const email = + typeof data.email === 'string' && data.email.trim() + ? data.email.trim() + : inferEmailFromTokenFileName(token.tokenFile, provider); + + const existingEntry = Object.entries(providerAccounts.accounts).find( + ([, account]) => account.tokenFile === token.tokenFile + ); + if (existingEntry) { + existingEntry[1].paused = token.paused || undefined; + if (!token.paused) { + existingEntry[1].pausedAt = undefined; + } + if (provider === 'agy' && projectId) { + existingEntry[1].projectId = projectId; + } + continue; + } + + const accountId = + PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email + ? deriveNoEmailProviderAccountId(provider, token.tokenFile, providerAccounts.accounts) + : extractAccountIdFromTokenFile(token.tokenFile, email); + + if (providerAccounts.accounts[accountId]) { + continue; + } + + if (Object.keys(providerAccounts.accounts).length === 0) { + providerAccounts.default = accountId; + } + + const stats = fs.statSync(token.filePath); + const accountMeta: Omit = { + email, + nickname: email ? generateNickname(email) : accountId, + tokenFile: token.tokenFile, + createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), + lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(), + }; + + if (token.paused) { + accountMeta.paused = true; + } + + if (provider === 'agy' && projectId) { + accountMeta.projectId = projectId; + } + + providerAccounts.accounts[accountId] = accountMeta; + } catch (error) { + issues.push({ + tokenFile: token.tokenFile, + paused: token.paused, + reason: getRegistryPopulationIssueReason(error), + }); + continue; + } + } + + return issues; +} + +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++; + } + + 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 { + const backupPath = backupCorruptedAccountsRegistry(registryPath); + const recovered = createDefaultRegistry(); + const issues = populateRegistryFromTokenFiles(recovered, { includePaused: true }); + writeAccountsRegistryToDisk(recovered); + + if (issues.length > 0) { + console.error( + `[!] Recovered corrupted account registry${backupPath ? `; backup saved to ${backupPath}` : ''}, but skipped ${issues.length} token file(s): ${issues + .map(describeRegistryPopulationIssue) + .join(', ')}` + ); + } else if (backupPath) { + console.error(`[i] Recovered corrupted account registry; backup saved to ${backupPath}`); + } + + return recovered; +} + function withAccountsRegistryLock(callback: () => T): T { const lockTarget = getCliproxyDir(); let release: (() => void) | undefined; @@ -64,12 +277,12 @@ function readAccountsRegistryFromDisk(): AccountsRegistry { let data: unknown; try { data = JSON.parse(content); - } catch (error) { - throw new Error(`Accounts registry is corrupted: ${(error as Error).message}`); + } catch { + return recoverAccountsRegistryFromCorruption(registryPath); } - if (!data || typeof data !== 'object') { - throw new Error('Accounts registry is corrupted: expected object'); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return recoverAccountsRegistryFromCorruption(registryPath); } const parsed = data as { version?: unknown; providers?: unknown }; @@ -113,7 +326,7 @@ function mutateAccountsRegistry(mutator: (registry: AccountsRegistry) => T): * Load accounts registry */ export function loadAccountsRegistry(): AccountsRegistry { - return readAccountsRegistryFromDisk(); + return withAccountsRegistryLock(() => readAccountsRegistryFromDisk()); } /** @@ -444,109 +657,11 @@ export function setAccountTier( * 2. Fallback: provider + index (e.g., kiro-1, kiro-2) */ export function discoverExistingAccounts(): void { - const authDir = getAuthDir(); - - if (!fs.existsSync(authDir)) { + if (!fs.existsSync(getAuthDir())) { return; } - - const files = fs.readdirSync(authDir); mutateAccountsRegistry((registry) => { syncRegistryWithTokenFiles(registry); - - for (const file of files) { - if (!file.endsWith('.json')) continue; - - const filePath = path.join(authDir, file); - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content); - if (!data.type) continue; - - const typeValue = data.type.toLowerCase(); - let provider: CLIProxyProvider | undefined; - for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { - if (typeValues.includes(typeValue)) { - provider = prov as CLIProxyProvider; - break; - } - } - - if (!provider) { - continue; - } - - let email = data.email || undefined; - if (!email && file.includes('@')) { - const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); - if (match) { - email = match[1]; - } - } - - if (!registry.providers[provider]) { - registry.providers[provider] = { - default: 'default', - accounts: {}, - }; - } - - const providerAccounts = registry.providers[provider]; - if (!providerAccounts) continue; - - const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile); - if (existingTokenFiles.includes(file)) { - const projectIdValue = - typeof data.project_id === 'string' && data.project_id.trim() - ? data.project_id.trim() - : null; - if (provider === 'agy' && projectIdValue) { - const existingEntry = Object.entries(providerAccounts.accounts).find( - ([, meta]) => meta.tokenFile === file - ); - if (existingEntry && existingEntry[1].projectId !== projectIdValue) { - existingEntry[1].projectId = projectIdValue; - } - } - continue; - } - - const accountId = - PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email - ? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts) - : extractAccountIdFromTokenFile(file, email); - - if (providerAccounts.accounts[accountId]) { - continue; - } - - if (Object.keys(providerAccounts.accounts).length === 0) { - providerAccounts.default = accountId; - } - - const stats = fs.statSync(filePath); - const lastModified = stats.mtime || stats.birthtime || new Date(); - const accountMeta: Omit = { - email, - nickname: email ? generateNickname(email) : accountId, - tokenFile: file, - createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), - lastUsedAt: lastModified.toISOString(), - }; - - const discoveredProjectId = - typeof data.project_id === 'string' && data.project_id.trim() - ? data.project_id.trim() - : null; - if (provider === 'agy' && discoveredProjectId) { - accountMeta.projectId = discoveredProjectId; - } - - providerAccounts.accounts[accountId] = accountMeta; - } catch { - continue; - } - } + populateRegistryFromTokenFiles(registry); }); } diff --git a/src/cliproxy/accounts/token-file-ops.ts b/src/cliproxy/accounts/token-file-ops.ts index 74b51e85..4df792fe 100644 --- a/src/cliproxy/accounts/token-file-ops.ts +++ b/src/cliproxy/accounts/token-file-ops.ts @@ -15,6 +15,16 @@ export function getAccountsRegistryPath(): string { return path.join(getCliproxyDir(), 'accounts.json'); } +export interface RecoverableTokenFile { + tokenFile: string; + filePath: string; + paused: boolean; +} + +interface ListRecoverableTokenFilesOptions { + includePaused?: boolean; +} + /** * Get path to paused tokens directory * Paused tokens are moved here so CLIProxyAPI won't discover them @@ -28,6 +38,43 @@ export function getPausedDir(): string { return path.join(getCliproxyDir(), 'auth-paused'); } +/** + * List token files that can be used to rebuild the accounts registry. + * Active auth tokens win over paused duplicates with the same filename. + */ +export function listRecoverableTokenFiles( + options: ListRecoverableTokenFilesOptions = {} +): RecoverableTokenFile[] { + const tokenFiles: RecoverableTokenFile[] = []; + const seen = new Set(); + const sources = [{ dir: getAuthDir(), paused: false }]; + + if (options.includePaused) { + sources.push({ dir: getPausedDir(), paused: true }); + } + + for (const source of sources) { + if (!fs.existsSync(source.dir)) { + continue; + } + + for (const tokenFile of fs.readdirSync(source.dir)) { + if (!tokenFile.endsWith('.json') || seen.has(tokenFile)) { + continue; + } + + tokenFiles.push({ + tokenFile, + filePath: path.join(source.dir, tokenFile), + paused: source.paused, + }); + seen.add(tokenFile); + } + } + + return tokenFiles; +} + /** * Get token file path for an account * Returns path in paused/ dir if account is paused, otherwise auth/ diff --git a/tests/unit/cliproxy/account-manager-discover.test.js b/tests/unit/cliproxy/account-manager-discover.test.js index 1a93f117..6bad8c5e 100644 --- a/tests/unit/cliproxy/account-manager-discover.test.js +++ b/tests/unit/cliproxy/account-manager-discover.test.js @@ -215,6 +215,19 @@ describe('Account Manager - discoverExistingAccounts', () => { const accountIds = Object.keys(accounts.providers.kiro.accounts); assert.strictEqual(accountIds[0], 'user+tag@example.com', 'Should handle plus addressing'); }); + + it('handles subdomain email addresses', () => { + createAuthFile('codex-user@mail.example.com.json', { + type: 'codex', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.codex.accounts); + assert.strictEqual(accountIds[0], 'user@mail.example.com', 'Should handle subdomains'); + }); }); // ========================================================================= diff --git a/tests/unit/cliproxy/account-registry-integrity.test.ts b/tests/unit/cliproxy/account-registry-integrity.test.ts index 6d0eb81d..441e94d0 100644 --- a/tests/unit/cliproxy/account-registry-integrity.test.ts +++ b/tests/unit/cliproxy/account-registry-integrity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -21,6 +21,30 @@ 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; + } +} + +function listRegistryBackups(registryPath: string): string[] { + return fs + .readdirSync(path.dirname(registryPath)) + .filter((file) => /^accounts\.json\.corrupt-.*\.bak$/.test(file)); +} + describe('account registry integrity', () => { it('does not create accounts.json during no-op discovery', async () => { await withIsolatedHome(async (homeDir) => { @@ -53,7 +77,10 @@ describe('account registry integrity', () => { const authDir = path.join(cliproxyDir, 'auth'); const registryPath = path.join(cliproxyDir, 'accounts.json'); fs.mkdirSync(authDir, { recursive: true }); - fs.writeFileSync(path.join(authDir, 'kiro-github-ABC123.json'), JSON.stringify({ type: 'kiro' })); + fs.writeFileSync( + path.join(authDir, 'kiro-github-ABC123.json'), + JSON.stringify({ type: 'kiro' }) + ); fs.writeFileSync( registryPath, JSON.stringify({ @@ -89,14 +116,252 @@ describe('account registry integrity', () => { }); }); - it('fails closed on corrupted accounts.json', async () => { + it('recovers corrupted accounts.json from token-backed accounts and preserves a backup', async () => { await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); - fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'codex-user@example.com.json'), + JSON.stringify({ type: 'codex', email: 'user@example.com' }), + 'utf8' + ); fs.writeFileSync(registryPath, '{not-valid-json', 'utf8'); + const { getProviderAccounts } = await loadAccountManager(); + const accounts = getProviderAccounts('codex'); const { loadAccountsRegistry } = await loadRegistryModule(); - expect(() => loadAccountsRegistry()).toThrow(/corrupted/i); + const registry = loadAccountsRegistry(); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.id).toBe('user@example.com'); + expect(accounts[0]?.isDefault).toBe(true); + expect(registry.providers.codex?.accounts['user@example.com']?.tokenFile).toBe( + 'codex-user@example.com.json' + ); + + const backups = listRegistryBackups(registryPath); + expect(backups).toHaveLength(1); + expect( + fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8') + ).toBe('{not-valid-json'); + }); + }); + + it('recovers paused token-backed accounts when rebuilding a corrupted registry', async () => { + await withIsolatedHome(async (homeDir) => { + const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused'); + const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); + fs.mkdirSync(pausedDir, { recursive: true }); + fs.writeFileSync( + path.join(pausedDir, 'codex-paused@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('paused@example.com'); + expect(accounts[0]?.paused).toBe(true); + }); + }); + + 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('logs successful corruption recovery even when every token file is recoverable', 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-clean@example.com.json'), + JSON.stringify({ type: 'codex', email: 'clean@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(messages).toHaveLength(1); + expect(messages[0]).toContain('Recovered corrupted account registry'); + expect(messages[0]).toContain('backup saved to'); + expect(messages[0]).not.toContain('skipped'); + }); + }); + + 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('infers subdomain emails from token file names 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-user@mail.example.com.json'), + JSON.stringify({ type: 'codex' }), + 'utf8' + ); + fs.writeFileSync(registryPath, '{"providers":', 'utf8'); + + const { getProviderAccounts } = await loadAccountManager(); + const accounts = getProviderAccounts('codex'); + + expect(accounts).toHaveLength(1); + expect(accounts[0]?.id).toBe('user@mail.example.com'); + expect(accounts[0]?.email).toBe('user@mail.example.com'); + expect(accounts[0]?.tokenFile).toBe('codex-user@mail.example.com.json'); + }); + }); + + it('preserves the corrupted registry backup when recovery cannot rewrite accounts.json', 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-user@example.com.json'), + JSON.stringify({ type: 'codex', email: 'user@example.com' }), + 'utf8' + ); + fs.writeFileSync(registryPath, '{"providers":', 'utf8'); + + const originalWriteFileSync = fs.writeFileSync; + const writeSpy = spyOn(fs, 'writeFileSync').mockImplementation(((file, data, options) => { + if (String(file).includes('accounts.json.tmp.')) { + throw new Error('simulated recovery write failure'); + } + + return originalWriteFileSync(file, data, options as never); + }) as typeof fs.writeFileSync); + + try { + const { loadAccountsRegistry } = await loadRegistryModule(); + + expect(() => loadAccountsRegistry()).toThrow('simulated recovery write failure'); + } finally { + writeSpy.mockRestore(); + } + + expect(fs.existsSync(registryPath)).toBe(false); + const backups = listRegistryBackups(registryPath); + expect(backups).toHaveLength(1); + expect( + fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8') + ).toBe('{"providers":'); + + const { loadAccountsRegistry } = await loadRegistryModule(); + expect(loadAccountsRegistry()).toEqual({ + version: 1, + providers: {}, + }); + }); + }); + + it('does not repopulate paused tokens during normal startup discovery', async () => { + await withIsolatedHome(async (homeDir) => { + const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused'); + const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json'); + fs.mkdirSync(pausedDir, { recursive: true }); + fs.writeFileSync( + path.join(pausedDir, 'codex-paused@example.com.json'), + JSON.stringify({ type: 'codex', email: 'paused@example.com' }), + 'utf8' + ); + + const { discoverExistingAccounts } = await loadRegistryModule(); + discoverExistingAccounts(); + + expect(fs.existsSync(registryPath)).toBe(false); + + const { getProviderAccounts } = await loadAccountManager(); + expect(getProviderAccounts('codex')).toEqual([]); + }); + }); + + it('recovers to an empty registry when no token files are available', async () => { + await withIsolatedHome(async (homeDir) => { + const cliproxyDir = path.join(homeDir, '.ccs', 'cliproxy'); + const registryPath = path.join(cliproxyDir, 'accounts.json'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync(registryPath, '{"providers":', 'utf8'); + + const { loadAccountsRegistry } = await loadRegistryModule(); + const { result: registry, messages } = await captureConsoleError(() => loadAccountsRegistry()); + + expect(registry).toEqual({ + version: 1, + providers: {}, + }); + expect(fs.existsSync(registryPath)).toBe(true); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('Recovered corrupted account registry'); + expect(messages[0]).toContain('backup saved to'); + expect(listRegistryBackups(registryPath)).toHaveLength(1); }); }); });