Merge pull request #798 from kaitranntt/kai/fix/797-codex-registry-recovery

fix(cliproxy): recover from corrupted accounts registry
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-26 13:57:22 -04:00
commit cdc122feb6
4 files changed
+550 -110

No files matched your search

+220 -105
View File
@@ -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<AccountsRegistry['providers'][CLIProxyProvider]> {
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<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
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<T>(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<T>(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<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
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);
});
}
+47
View File
@@ -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<string>();
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/
@@ -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');
});
});
// =========================================================================
@@ -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<T>(fn: () => Promise<T> | 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);
});
});
});