mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 06:18:55 +00:00
fix(cliproxy): recover corrupted account registries from token files
This commit is contained in:
+139
-105
@@ -21,6 +21,7 @@ import {
|
|||||||
moveTokenToPaused,
|
moveTokenToPaused,
|
||||||
moveTokenFromPaused,
|
moveTokenFromPaused,
|
||||||
deleteTokenFile,
|
deleteTokenFile,
|
||||||
|
listRecoverableTokenFiles,
|
||||||
} from './token-file-ops';
|
} from './token-file-ops';
|
||||||
|
|
||||||
/** Default registry structure */
|
/** Default registry structure */
|
||||||
@@ -31,6 +32,137 @@ function createDefaultRegistry(): AccountsRegistry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureProviderRegistry(
|
||||||
|
registry: AccountsRegistry,
|
||||||
|
provider: CLIProxyProvider
|
||||||
|
): NonNullable<AccountsRegistry['providers'][CLIProxyProvider]> {
|
||||||
|
if (!registry.providers[provider]) {
|
||||||
|
registry.providers[provider] = {
|
||||||
|
default: 'default',
|
||||||
|
accounts: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.providers[provider] as NonNullable<
|
||||||
|
AccountsRegistry['providers'][CLIProxyProvider]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderFromTokenType(typeValue: string): CLIProxyProvider | undefined {
|
||||||
|
for (const [provider, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
|
||||||
|
if (typeValues.includes(typeValue)) {
|
||||||
|
return provider as CLIProxyProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferEmailFromTokenFileName(tokenFile: string): string | undefined {
|
||||||
|
const match = tokenFile.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateRegistryFromTokenFiles(
|
||||||
|
registry: AccountsRegistry,
|
||||||
|
options: { includePaused?: boolean } = {}
|
||||||
|
): void {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
if (!data.type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = resolveProviderFromTokenType(data.type.toLowerCase());
|
||||||
|
if (!provider) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerAccounts = ensureProviderRegistry(registry, provider);
|
||||||
|
const projectId =
|
||||||
|
typeof data.project_id === 'string' && data.project_id.trim()
|
||||||
|
? data.project_id.trim()
|
||||||
|
: null;
|
||||||
|
const email =
|
||||||
|
typeof data.email === 'string' && data.email.trim()
|
||||||
|
? data.email.trim()
|
||||||
|
: inferEmailFromTokenFileName(token.tokenFile);
|
||||||
|
|
||||||
|
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||||
|
([, account]) => account.tokenFile === token.tokenFile
|
||||||
|
);
|
||||||
|
if (existingEntry) {
|
||||||
|
existingEntry[1].paused = token.paused || undefined;
|
||||||
|
if (!token.paused) {
|
||||||
|
existingEntry[1].pausedAt = undefined;
|
||||||
|
}
|
||||||
|
if (provider === 'agy' && projectId) {
|
||||||
|
existingEntry[1].projectId = projectId;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId =
|
||||||
|
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
|
||||||
|
? deriveNoEmailProviderAccountId(provider, token.tokenFile, providerAccounts.accounts)
|
||||||
|
: extractAccountIdFromTokenFile(token.tokenFile, email);
|
||||||
|
|
||||||
|
if (providerAccounts.accounts[accountId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||||
|
providerAccounts.default = accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(token.filePath);
|
||||||
|
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||||
|
email,
|
||||||
|
nickname: email ? generateNickname(email) : accountId,
|
||||||
|
tokenFile: token.tokenFile,
|
||||||
|
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||||
|
lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token.paused) {
|
||||||
|
accountMeta.paused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'agy' && projectId) {
|
||||||
|
accountMeta.projectId = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
providerAccounts.accounts[accountId] = accountMeta;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupCorruptedAccountsRegistry(registryPath: string): void {
|
||||||
|
if (!fs.existsSync(registryPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const backupPath = `${registryPath}.corrupt-${timestamp}.bak`;
|
||||||
|
fs.renameSync(registryPath, backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverAccountsRegistryFromCorruption(registryPath: string): AccountsRegistry {
|
||||||
|
backupCorruptedAccountsRegistry(registryPath);
|
||||||
|
const recovered = createDefaultRegistry();
|
||||||
|
populateRegistryFromTokenFiles(recovered, { includePaused: true });
|
||||||
|
writeAccountsRegistryToDisk(recovered);
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
function withAccountsRegistryLock<T>(callback: () => T): T {
|
function withAccountsRegistryLock<T>(callback: () => T): T {
|
||||||
const lockTarget = getCliproxyDir();
|
const lockTarget = getCliproxyDir();
|
||||||
let release: (() => void) | undefined;
|
let release: (() => void) | undefined;
|
||||||
@@ -64,12 +196,12 @@ function readAccountsRegistryFromDisk(): AccountsRegistry {
|
|||||||
let data: unknown;
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(content);
|
data = JSON.parse(content);
|
||||||
} catch (error) {
|
} catch {
|
||||||
throw new Error(`Accounts registry is corrupted: ${(error as Error).message}`);
|
return recoverAccountsRegistryFromCorruption(registryPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || typeof data !== 'object') {
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||||
throw new Error('Accounts registry is corrupted: expected object');
|
return recoverAccountsRegistryFromCorruption(registryPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = data as { version?: unknown; providers?: unknown };
|
const parsed = data as { version?: unknown; providers?: unknown };
|
||||||
@@ -113,7 +245,7 @@ function mutateAccountsRegistry<T>(mutator: (registry: AccountsRegistry) => T):
|
|||||||
* Load accounts registry
|
* Load accounts registry
|
||||||
*/
|
*/
|
||||||
export function loadAccountsRegistry(): AccountsRegistry {
|
export function loadAccountsRegistry(): AccountsRegistry {
|
||||||
return readAccountsRegistryFromDisk();
|
return withAccountsRegistryLock(() => readAccountsRegistryFromDisk());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,109 +576,11 @@ export function setAccountTier(
|
|||||||
* 2. Fallback: provider + index (e.g., kiro-1, kiro-2)
|
* 2. Fallback: provider + index (e.g., kiro-1, kiro-2)
|
||||||
*/
|
*/
|
||||||
export function discoverExistingAccounts(): void {
|
export function discoverExistingAccounts(): void {
|
||||||
const authDir = getAuthDir();
|
if (!fs.existsSync(getAuthDir())) {
|
||||||
|
|
||||||
if (!fs.existsSync(authDir)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = fs.readdirSync(authDir);
|
|
||||||
mutateAccountsRegistry((registry) => {
|
mutateAccountsRegistry((registry) => {
|
||||||
syncRegistryWithTokenFiles(registry);
|
syncRegistryWithTokenFiles(registry);
|
||||||
|
populateRegistryFromTokenFiles(registry);
|
||||||
for (const file of files) {
|
|
||||||
if (!file.endsWith('.json')) continue;
|
|
||||||
|
|
||||||
const filePath = path.join(authDir, file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const data = JSON.parse(content);
|
|
||||||
if (!data.type) continue;
|
|
||||||
|
|
||||||
const typeValue = data.type.toLowerCase();
|
|
||||||
let provider: CLIProxyProvider | undefined;
|
|
||||||
for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
|
|
||||||
if (typeValues.includes(typeValue)) {
|
|
||||||
provider = prov as CLIProxyProvider;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let email = data.email || undefined;
|
|
||||||
if (!email && file.includes('@')) {
|
|
||||||
const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
|
|
||||||
if (match) {
|
|
||||||
email = match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!registry.providers[provider]) {
|
|
||||||
registry.providers[provider] = {
|
|
||||||
default: 'default',
|
|
||||||
accounts: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerAccounts = registry.providers[provider];
|
|
||||||
if (!providerAccounts) continue;
|
|
||||||
|
|
||||||
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
|
|
||||||
if (existingTokenFiles.includes(file)) {
|
|
||||||
const projectIdValue =
|
|
||||||
typeof data.project_id === 'string' && data.project_id.trim()
|
|
||||||
? data.project_id.trim()
|
|
||||||
: null;
|
|
||||||
if (provider === 'agy' && projectIdValue) {
|
|
||||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
|
||||||
([, meta]) => meta.tokenFile === file
|
|
||||||
);
|
|
||||||
if (existingEntry && existingEntry[1].projectId !== projectIdValue) {
|
|
||||||
existingEntry[1].projectId = projectIdValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountId =
|
|
||||||
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
|
|
||||||
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
|
|
||||||
: extractAccountIdFromTokenFile(file, email);
|
|
||||||
|
|
||||||
if (providerAccounts.accounts[accountId]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
|
||||||
providerAccounts.default = accountId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
const lastModified = stats.mtime || stats.birthtime || new Date();
|
|
||||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
|
||||||
email,
|
|
||||||
nickname: email ? generateNickname(email) : accountId,
|
|
||||||
tokenFile: file,
|
|
||||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
|
||||||
lastUsedAt: lastModified.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const discoveredProjectId =
|
|
||||||
typeof data.project_id === 'string' && data.project_id.trim()
|
|
||||||
? data.project_id.trim()
|
|
||||||
: null;
|
|
||||||
if (provider === 'agy' && discoveredProjectId) {
|
|
||||||
accountMeta.projectId = discoveredProjectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
providerAccounts.accounts[accountId] = accountMeta;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export function getAccountsRegistryPath(): string {
|
|||||||
return path.join(getCliproxyDir(), 'accounts.json');
|
return path.join(getCliproxyDir(), 'accounts.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecoverableTokenFile {
|
||||||
|
tokenFile: string;
|
||||||
|
filePath: string;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListRecoverableTokenFilesOptions {
|
||||||
|
includePaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get path to paused tokens directory
|
* Get path to paused tokens directory
|
||||||
* Paused tokens are moved here so CLIProxyAPI won't discover them
|
* Paused tokens are moved here so CLIProxyAPI won't discover them
|
||||||
@@ -28,6 +38,43 @@ export function getPausedDir(): string {
|
|||||||
return path.join(getCliproxyDir(), 'auth-paused');
|
return path.join(getCliproxyDir(), 'auth-paused');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List token files that can be used to rebuild the accounts registry.
|
||||||
|
* Active auth tokens win over paused duplicates with the same filename.
|
||||||
|
*/
|
||||||
|
export function listRecoverableTokenFiles(
|
||||||
|
options: ListRecoverableTokenFilesOptions = {}
|
||||||
|
): RecoverableTokenFile[] {
|
||||||
|
const tokenFiles: RecoverableTokenFile[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const sources = [{ dir: getAuthDir(), paused: false }];
|
||||||
|
|
||||||
|
if (options.includePaused) {
|
||||||
|
sources.push({ dir: getPausedDir(), paused: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!fs.existsSync(source.dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tokenFile of fs.readdirSync(source.dir)) {
|
||||||
|
if (!tokenFile.endsWith('.json') || seen.has(tokenFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenFiles.push({
|
||||||
|
tokenFile,
|
||||||
|
filePath: path.join(source.dir, tokenFile),
|
||||||
|
paused: source.paused,
|
||||||
|
});
|
||||||
|
seen.add(tokenFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenFiles;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get token file path for an account
|
* Get token file path for an account
|
||||||
* Returns path in paused/ dir if account is paused, otherwise auth/
|
* Returns path in paused/ dir if account is paused, otherwise auth/
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ describe('account registry integrity', () => {
|
|||||||
const authDir = path.join(cliproxyDir, 'auth');
|
const authDir = path.join(cliproxyDir, 'auth');
|
||||||
const registryPath = path.join(cliproxyDir, 'accounts.json');
|
const registryPath = path.join(cliproxyDir, 'accounts.json');
|
||||||
fs.mkdirSync(authDir, { recursive: true });
|
fs.mkdirSync(authDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(authDir, 'kiro-github-ABC123.json'), JSON.stringify({ type: 'kiro' }));
|
fs.writeFileSync(
|
||||||
|
path.join(authDir, 'kiro-github-ABC123.json'),
|
||||||
|
JSON.stringify({ type: 'kiro' })
|
||||||
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
registryPath,
|
registryPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -89,14 +92,79 @@ describe('account registry integrity', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails closed on corrupted accounts.json', async () => {
|
it('recovers corrupted accounts.json from token-backed accounts and preserves a backup', async () => {
|
||||||
await withIsolatedHome(async (homeDir) => {
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth');
|
||||||
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
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, '{not-valid-json', 'utf8');
|
fs.writeFileSync(registryPath, '{not-valid-json', 'utf8');
|
||||||
|
|
||||||
|
const { getProviderAccounts } = await loadAccountManager();
|
||||||
|
const accounts = getProviderAccounts('codex');
|
||||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||||
expect(() => loadAccountsRegistry()).toThrow(/corrupted/i);
|
const registry = loadAccountsRegistry();
|
||||||
|
|
||||||
|
expect(accounts).toHaveLength(1);
|
||||||
|
expect(accounts[0]?.id).toBe('user@example.com');
|
||||||
|
expect(accounts[0]?.isDefault).toBe(true);
|
||||||
|
expect(registry.providers.codex?.accounts['user@example.com']?.tokenFile).toBe(
|
||||||
|
'codex-user@example.com.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const backups = fs
|
||||||
|
.readdirSync(path.dirname(registryPath))
|
||||||
|
.filter((file) => /^accounts\.json\.corrupt-.*\.bak$/.test(file));
|
||||||
|
expect(backups).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(path.dirname(registryPath), backups[0] as string), 'utf8')
|
||||||
|
).toBe('{not-valid-json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers paused token-backed accounts when rebuilding a corrupted registry', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
|
||||||
|
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||||
|
fs.mkdirSync(pausedDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pausedDir, 'codex-paused@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('paused@example.com');
|
||||||
|
expect(accounts[0]?.paused).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repopulate paused tokens during normal startup discovery', async () => {
|
||||||
|
await withIsolatedHome(async (homeDir) => {
|
||||||
|
const pausedDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth-paused');
|
||||||
|
const registryPath = path.join(homeDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||||
|
fs.mkdirSync(pausedDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pausedDir, 'codex-paused@example.com.json'),
|
||||||
|
JSON.stringify({ type: 'codex', email: 'paused@example.com' }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { discoverExistingAccounts } = await loadRegistryModule();
|
||||||
|
discoverExistingAccounts();
|
||||||
|
|
||||||
|
expect(fs.existsSync(registryPath)).toBe(false);
|
||||||
|
|
||||||
|
const { getProviderAccounts } = await loadAccountManager();
|
||||||
|
expect(getProviderAccounts('codex')).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user