diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index 0f94ffac..c230c89e 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -58,9 +58,30 @@ function resolveProviderFromTokenType(typeValue: string): CLIProxyProvider | und return undefined; } -function inferEmailFromTokenFileName(tokenFile: string): string | undefined { - const match = tokenFile.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); - return match?.[1]; +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 { @@ -120,7 +141,7 @@ function populateRegistryFromTokenFiles( const email = typeof data.email === 'string' && data.email.trim() ? data.email.trim() - : inferEmailFromTokenFileName(token.tokenFile); + : inferEmailFromTokenFileName(token.tokenFile, provider); const existingEntry = Object.entries(providerAccounts.accounts).find( ([, account]) => account.tokenFile === token.tokenFile @@ -211,12 +232,13 @@ function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRe 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 + `[!] 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; 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 ff5becd2..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'; @@ -39,6 +39,12 @@ async function captureConsoleError(fn: () => Promise | T): Promise<{ resul } } +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) => { @@ -134,9 +140,7 @@ describe('account registry integrity', () => { 'codex-user@example.com.json' ); - const backups = fs - .readdirSync(path.dirname(registryPath)) - .filter((file) => /^accounts\.json\.corrupt-.*\.bak$/.test(file)); + const backups = listRegistryBackups(registryPath); expect(backups).toHaveLength(1); expect( fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8') @@ -196,6 +200,31 @@ describe('account registry integrity', () => { }); }); + 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'); @@ -227,6 +256,72 @@ describe('account registry integrity', () => { }); }); + 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'); @@ -247,4 +342,26 @@ describe('account registry integrity', () => { 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); + }); + }); });