mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +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];
|
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(
|
function populateRegistryFromTokenFiles(
|
||||||
registry: AccountsRegistry,
|
registry: AccountsRegistry,
|
||||||
options: { includePaused?: boolean } = {}
|
options: { includePaused?: boolean } = {}
|
||||||
): void {
|
): RegistryPopulationIssue[] {
|
||||||
|
const issues: RegistryPopulationIssue[] = [];
|
||||||
|
|
||||||
for (const token of listRecoverableTokenFiles(options)) {
|
for (const token of listRecoverableTokenFiles(options)) {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(token.filePath, 'utf-8');
|
const content = fs.readFileSync(token.filePath, 'utf-8');
|
||||||
const data = JSON.parse(content) as {
|
const data = JSON.parse(content) as {
|
||||||
type?: string;
|
type?: unknown;
|
||||||
email?: string;
|
email?: unknown;
|
||||||
project_id?: string;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,27 +167,58 @@ function populateRegistryFromTokenFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
providerAccounts.accounts[accountId] = accountMeta;
|
providerAccounts.accounts[accountId] = accountMeta;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
issues.push({
|
||||||
|
tokenFile: token.tokenFile,
|
||||||
|
paused: token.paused,
|
||||||
|
reason: getRegistryPopulationIssueReason(error),
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
function backupCorruptedAccountsRegistry(registryPath: string): void {
|
function getCorruptedRegistryBackupPath(registryPath: string): string {
|
||||||
if (!fs.existsSync(registryPath)) {
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
return;
|
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, '-');
|
return backupPath;
|
||||||
const backupPath = `${registryPath}.corrupt-${timestamp}.bak`;
|
}
|
||||||
|
|
||||||
|
function backupCorruptedAccountsRegistry(registryPath: string): string | null {
|
||||||
|
if (!fs.existsSync(registryPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupPath = getCorruptedRegistryBackupPath(registryPath);
|
||||||
fs.renameSync(registryPath, backupPath);
|
fs.renameSync(registryPath, backupPath);
|
||||||
|
return backupPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRegistry {
|
function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRegistry {
|
||||||
backupCorruptedAccountsRegistry(registryPath);
|
const backupPath = backupCorruptedAccountsRegistry(registryPath);
|
||||||
const recovered = createDefaultRegistry();
|
const recovered = createDefaultRegistry();
|
||||||
populateRegistryFromTokenFiles(recovered, { includePaused: true });
|
const issues = populateRegistryFromTokenFiles(recovered, { includePaused: true });
|
||||||
writeAccountsRegistryToDisk(recovered);
|
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;
|
return recovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ async function loadAccountManager() {
|
|||||||
return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`);
|
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', () => {
|
describe('account registry integrity', () => {
|
||||||
it('does not create accounts.json during no-op discovery', async () => {
|
it('does not create accounts.json during no-op discovery', async () => {
|
||||||
await withIsolatedHome(async (homeDir) => {
|
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 () => {
|
it('does not repopulate paused tokens during normal startup discovery', async () => {
|
||||||
await withIsolatedHome(async (homeDir) => {
|
await withIsolatedHome(async (homeDir) => {
|
||||||
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
|
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
|
||||||
|
|||||||
Reference in New Issue
Block a user