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