diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index a6a07a6d..35c36bd1 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -2,6 +2,9 @@ import type { CLIProxyProvider } from '../types'; const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['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 { return provider.trim().toLowerCase(); } diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 6adf5e72..43fdd606 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -197,6 +197,8 @@ function readCodexAuthFile(filePath: string): CodexAuthData | null { function readCodexAuthData(accountId: string): CodexAuthData | null { const authDirs = [getAuthDir(), getPausedDir()]; const registryAccount = getAccount('codex', accountId); + const canonicalEmail = extractCanonicalEmailFromAccountId(accountId); + const hasExplicitVariant = canonicalEmail !== null && canonicalEmail !== accountId; if (registryAccount?.tokenFile) { for (const authDir of authDirs) { 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 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); for (const file of files) { if (file.startsWith('codex-') && file.endsWith('.json')) { diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index dd4a4c04..af6bc479 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -365,14 +365,18 @@ describe('Codex Quota Fetcher', () => { }); describe('fetchCodexQuota failure mapping', () => { - function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void { + function createValidCodexAccount( + email: string, + accountId = `workspace-${email}`, + tokenFile?: string + ): void { createCodexAccount(email, { access_token: 'test-token', account_id: accountId, expired: '2099-01-01T00:00:00.000Z', email, type: 'codex', - }); + }, tokenFile); } 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'); }); + 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 () => { createValidCodexAccount('forbidden@example.com', 'workspace-forbidden'); diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index 167e7ad3..eac2beda 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -1,6 +1,9 @@ const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']); 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 interface AccountIdentityPresentation { diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index e6c18c2c..749c63f3 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -912,15 +912,17 @@ export const api = { body: JSON.stringify({ accountId }), }), 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) => request<{ provider: string; accountId: string; paused: boolean }>( - `/cliproxy/auth/accounts/${provider}/${accountId}/pause`, + `/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/pause`, { method: 'POST' } ), resume: (provider: string, accountId: string) => request<{ provider: string; accountId: string; paused: boolean }>( - `/cliproxy/auth/accounts/${provider}/${accountId}/resume`, + `/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/resume`, { method: 'POST' } ), /** Solo mode: activate one account, pause all others */ diff --git a/ui/tests/unit/ui/lib/api-client.test.ts b/ui/tests/unit/ui/lib/api-client.test.ts new file mode 100644 index 00000000..de5baf87 --- /dev/null +++ b/ui/tests/unit/ui/lib/api-client.test.ts @@ -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` + ); + }); +});