mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(cliproxy): log clean registry recovery and harden email fallback
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user