fix(cliproxy): harden corrupted registry recovery

This commit is contained in:
Tam Nhu Tran
2026-03-26 10:58:42 -04:00
parent d53873bddd
commit 313e244302
2 changed files with 152 additions and 13 deletions
+72 -13
View File
@@ -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;
}
@@ -21,6 +21,24 @@ 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;
}
}
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');