fix(cliproxy): log clean registry recovery and harden email fallback

This commit is contained in:
Tam Nhu Tran
2026-03-26 13:40:37 -04:00
parent 313e244302
commit 63f48622e5
3 changed files with 162 additions and 10 deletions
+28 -6
View File
@@ -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;
@@ -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';
@@ -39,6 +39,12 @@ async function captureConsoleError<T>(fn: () => Promise<T> | 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);
});
});
});