mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-09 00:20:06 +00:00
fix(codex): harden duplicate-email account actions
This commit is contained in:
@@ -2,6 +2,9 @@ import type { CLIProxyProvider } from '../types';
|
|||||||
|
|
||||||
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
||||||
|
|
||||||
|
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
|
||||||
|
// separate because the browser bundle cannot import this server module directly.
|
||||||
|
|
||||||
function normalizeProvider(provider: CLIProxyProvider | string): string {
|
function normalizeProvider(provider: CLIProxyProvider | string): string {
|
||||||
return provider.trim().toLowerCase();
|
return provider.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ function readCodexAuthFile(filePath: string): CodexAuthData | null {
|
|||||||
function readCodexAuthData(accountId: string): CodexAuthData | null {
|
function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||||
const authDirs = [getAuthDir(), getPausedDir()];
|
const authDirs = [getAuthDir(), getPausedDir()];
|
||||||
const registryAccount = getAccount('codex', accountId);
|
const registryAccount = getAccount('codex', accountId);
|
||||||
|
const canonicalEmail = extractCanonicalEmailFromAccountId(accountId);
|
||||||
|
const hasExplicitVariant = canonicalEmail !== null && canonicalEmail !== accountId;
|
||||||
if (registryAccount?.tokenFile) {
|
if (registryAccount?.tokenFile) {
|
||||||
for (const authDir of authDirs) {
|
for (const authDir of authDirs) {
|
||||||
const filePath = path.join(authDir, registryAccount.tokenFile);
|
const filePath = path.join(authDir, registryAccount.tokenFile);
|
||||||
@@ -211,7 +213,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyEmail = extractCanonicalEmailFromAccountId(accountId) ?? accountId;
|
const legacyEmail = canonicalEmail ?? accountId;
|
||||||
const sanitizedId = sanitizeEmail(legacyEmail);
|
const sanitizedId = sanitizeEmail(legacyEmail);
|
||||||
const expectedFile = `codex-${sanitizedId}.json`;
|
const expectedFile = `codex-${sanitizedId}.json`;
|
||||||
|
|
||||||
@@ -226,7 +228,13 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: scan directory for matching email in file content
|
// Fallback is only safe for legacy email-only IDs. Variant-backed IDs must resolve
|
||||||
|
// through the registry-backed token file so duplicate-email accounts stay deterministic.
|
||||||
|
if (hasExplicitVariant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scan directory for matching email in file content.
|
||||||
const files = fs.readdirSync(authDir);
|
const files = fs.readdirSync(authDir);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.startsWith('codex-') && file.endsWith('.json')) {
|
if (file.startsWith('codex-') && file.endsWith('.json')) {
|
||||||
|
|||||||
@@ -365,14 +365,18 @@ describe('Codex Quota Fetcher', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchCodexQuota failure mapping', () => {
|
describe('fetchCodexQuota failure mapping', () => {
|
||||||
function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void {
|
function createValidCodexAccount(
|
||||||
|
email: string,
|
||||||
|
accountId = `workspace-${email}`,
|
||||||
|
tokenFile?: string
|
||||||
|
): void {
|
||||||
createCodexAccount(email, {
|
createCodexAccount(email, {
|
||||||
access_token: 'test-token',
|
access_token: 'test-token',
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
expired: '2099-01-01T00:00:00.000Z',
|
expired: '2099-01-01T00:00:00.000Z',
|
||||||
email,
|
email,
|
||||||
type: 'codex',
|
type: 'codex',
|
||||||
});
|
}, tokenFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('maps deactivated workspace 402 responses to structured metadata', async () => {
|
it('maps deactivated workspace 402 responses to structured metadata', async () => {
|
||||||
@@ -460,6 +464,43 @@ describe('Codex Quota Fetcher', () => {
|
|||||||
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
|
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not guess a duplicate-email Codex auth file when the registry entry is missing', async () => {
|
||||||
|
createValidCodexAccount(
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'workspace-team',
|
||||||
|
'codex-legacy-slot-a.json'
|
||||||
|
);
|
||||||
|
createValidCodexAccount(
|
||||||
|
'kaidu.kd@gmail.com',
|
||||||
|
'workspace-free',
|
||||||
|
'codex-legacy-slot-b.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchSpy = mock(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
plan_type: 'free',
|
||||||
|
rate_limit: {
|
||||||
|
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) as typeof fetch;
|
||||||
|
global.fetch = fetchSpy;
|
||||||
|
|
||||||
|
const result = await fetchCodexQuota('kaidu.kd@gmail.com#04a0f049-team');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('auth_file_missing');
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('maps 403 responses to forbidden metadata', async () => {
|
it('maps 403 responses to forbidden metadata', async () => {
|
||||||
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
|
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
|
||||||
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||||
|
|
||||||
|
// Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts.
|
||||||
|
// This browser copy stays local because the server module is not bundle-safe for the UI.
|
||||||
|
|
||||||
export type AccountAudience = 'business' | 'personal' | 'unknown';
|
export type AccountAudience = 'business' | 'personal' | 'unknown';
|
||||||
|
|
||||||
export interface AccountIdentityPresentation {
|
export interface AccountIdentityPresentation {
|
||||||
|
|||||||
@@ -912,15 +912,17 @@ export const api = {
|
|||||||
body: JSON.stringify({ accountId }),
|
body: JSON.stringify({ accountId }),
|
||||||
}),
|
}),
|
||||||
remove: (provider: string, accountId: string) =>
|
remove: (provider: string, accountId: string) =>
|
||||||
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
request(`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
pause: (provider: string, accountId: string) =>
|
pause: (provider: string, accountId: string) =>
|
||||||
request<{ provider: string; accountId: string; paused: boolean }>(
|
request<{ provider: string; accountId: string; paused: boolean }>(
|
||||||
`/cliproxy/auth/accounts/${provider}/${accountId}/pause`,
|
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/pause`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
),
|
),
|
||||||
resume: (provider: string, accountId: string) =>
|
resume: (provider: string, accountId: string) =>
|
||||||
request<{ provider: string; accountId: string; paused: boolean }>(
|
request<{ provider: string; accountId: string; paused: boolean }>(
|
||||||
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
|
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/resume`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
),
|
),
|
||||||
/** Solo mode: activate one account, pause all others */
|
/** Solo mode: activate one account, pause all others */
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
|
||||||
|
function createEmptyJsonResponse(status = 200): Response {
|
||||||
|
return new Response('{}', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cliproxy account API client', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes duplicate-email account ids for account auth actions', async () => {
|
||||||
|
const fetchMock = vi.fn(() => Promise.resolve(createEmptyJsonResponse()));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const accountId = 'kaidu.kd@gmail.com#04a0f049-team';
|
||||||
|
const encodedAccountId = encodeURIComponent(accountId);
|
||||||
|
|
||||||
|
await api.cliproxy.accounts.remove('codex', accountId);
|
||||||
|
await api.cliproxy.accounts.pause('codex', accountId);
|
||||||
|
await api.cliproxy.accounts.resume('codex', accountId);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||||
|
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}`
|
||||||
|
);
|
||||||
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||||
|
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/pause`
|
||||||
|
);
|
||||||
|
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
||||||
|
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/resume`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user