diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts index 7a913713..d51e3af9 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts @@ -382,6 +382,44 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].featureLabel).toBe('Custom-Feature'); expect(windows[0].usedPercent).toBe(50); }); + + it('should remove terminal control characters from additional limit labels', () => { + const response = { + additional_rate_limits: [ + { + limit_name: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007', + rate_limit: { + primary_window: { used_percent: 25, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(windows[0].label).toBe('GPT-5.3-Codex-Spark (Primary)'); + }); + + it('should bound additional limit labels before storing them', () => { + const response = { + additional_rate_limits: [ + { + limit_name: `Feature-${'x'.repeat(120)}`, + rate_limit: { + primary_window: { used_percent: 25, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].featureLabel).toHaveLength(80); + expect(windows[0].label).toBe(`${windows[0].featureLabel} (Primary)`); + }); }); describe('buildCodexCoreUsageSummary', () => { diff --git a/src/cliproxy/quota/quota-fetcher-codex.ts b/src/cliproxy/quota/quota-fetcher-codex.ts index 78ab225b..c9d66a94 100644 --- a/src/cliproxy/quota/quota-fetcher-codex.ts +++ b/src/cliproxy/quota/quota-fetcher-codex.ts @@ -11,6 +11,7 @@ import { getAuthDir } from '../config/config-generator'; import { getAccount, getProviderAccounts, getPausedDir } from '../accounts/account-manager'; import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils'; import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types'; +import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer'; import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity'; /** ChatGPT backend API base URL */ @@ -58,8 +59,8 @@ interface CodexRateLimitWindow { * Each entry surfaces its own primary/secondary windows under a feature-specific limit name. */ interface CodexAdditionalRateLimit { - limit_name?: string; - limitName?: string; + limit_name?: unknown; + limitName?: unknown; metered_feature?: string; meteredFeature?: string; rate_limit?: CodexRateLimitWindow; @@ -380,11 +381,7 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] const entryRateLimit = entry.rate_limit || entry.rateLimit; if (!entryRateLimit) continue; - const rawFeatureLabel = entry.limit_name ?? entry.limitName; - const featureLabel = - typeof rawFeatureLabel === 'string' && rawFeatureLabel.trim().length > 0 - ? rawFeatureLabel.trim() - : 'Additional'; + const featureLabel = sanitizeCodexFeatureLabel(entry.limit_name ?? entry.limitName); addWindow( `${featureLabel} (Primary)`, entryRateLimit.primary_window || entryRateLimit.primaryWindow, diff --git a/src/cliproxy/quota/quota-label-sanitizer.ts b/src/cliproxy/quota/quota-label-sanitizer.ts new file mode 100644 index 00000000..b7279eb1 --- /dev/null +++ b/src/cliproxy/quota/quota-label-sanitizer.ts @@ -0,0 +1,29 @@ +const CODEX_FEATURE_LABEL_FALLBACK = 'Additional'; +const CODEX_FEATURE_LABEL_MAX_LENGTH = 80; +const TERMINAL_ESCAPE_SEQUENCE_REGEX = + /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]/g; +const TERMINAL_CONTROL_CHARS_REGEX = /[\u0000-\u001f\u007f-\u009f]/g; + +/** + * Sanitize upstream Codex feature labels before storing or rendering them. + * + * The quota API is not schema-validated at runtime, so additional-rate-limit + * labels must be constrained to safe, printable strings before they reach the + * terminal. + */ +export function sanitizeCodexFeatureLabelOrNull(value: unknown): string | null { + if (typeof value !== 'string') return null; + + const sanitized = value + .replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '') + .replace(TERMINAL_CONTROL_CHARS_REGEX, '') + .trim() + .slice(0, CODEX_FEATURE_LABEL_MAX_LENGTH) + .trimEnd(); + + return sanitized.length > 0 ? sanitized : null; +} + +export function sanitizeCodexFeatureLabel(value: unknown): string { + return sanitizeCodexFeatureLabelOrNull(value) ?? CODEX_FEATURE_LABEL_FALLBACK; +} diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 5094e4e2..176e2aa4 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,6 +18,10 @@ import { } from '../../cliproxy/accounts/account-manager'; import { fetchAllProviderQuotas } from '../../cliproxy/quota/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota/quota-fetcher-codex'; +import { + sanitizeCodexFeatureLabel, + sanitizeCodexFeatureLabelOrNull, +} from '../../cliproxy/quota/quota-label-sanitizer'; import { fetchAllClaudeQuotas } from '../../cliproxy/quota/quota-fetcher-claude'; import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota/quota-fetcher-claude-normalizer'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota/quota-fetcher-gemini-cli'; @@ -279,9 +283,12 @@ function inferCodeReviewCadence( * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the * remainder into a Codex-prefixed display name. Other labels pass through unchanged. */ -function prettifyCodexFeatureLabel(featureLabel: string): string { - const trimmed = featureLabel.trim(); - if (!trimmed) return 'Additional'; +function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string { + const trimmed = + sanitizeCodexFeatureLabelOrNull(featureLabel) ?? + (fallbackLabel === undefined + ? sanitizeCodexFeatureLabel(featureLabel) + : sanitizeCodexFeatureLabel(fallbackLabel)); const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); if (stripped !== trimmed && stripped.length > 0) { return `Codex ${stripped}`; @@ -302,7 +309,7 @@ function getCodexWindowDisplayLabel( } if (window.category === 'additional') { - const pretty = prettifyCodexFeatureLabel(window.featureLabel || window.label || 'Additional'); + const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label); if (window.cadence === '5h') return `${pretty} (5h)`; if (window.cadence === 'weekly') return `${pretty} (weekly)`; return pretty; @@ -850,7 +857,9 @@ const QUOTA_PROVIDER_RUNTIME: Record { expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro'); }); }); + +describe('cliproxy quota subcommand Codex label formatting', () => { + it('falls back to the cached window label for invalid Codex feature labels', async () => { + const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); + + const cases = [ + { featureLabel: '', cadence: '5h', expected: 'Codex Spark (5h)' }, + { featureLabel: ' ', cadence: 'weekly', expected: 'Codex Spark (weekly)' }, + { + featureLabel: '\u001b[2J\u001b]52;c;payload\u0007', + cadence: '5h', + expected: 'Codex Spark (5h)', + }, + { featureLabel: { unexpected: true }, cadence: '5h', expected: 'Codex Spark (5h)' }, + ] as const; + + for (const { featureLabel, cadence, expected } of cases) { + const label = getCodexWindowDisplayLabel({ + label: 'GPT-5.3-Codex-Spark', + resetAfterSeconds: 3600, + category: 'additional', + cadence, + featureLabel, + } as never); + + expect(label).toBe(expected); + } + }); + + it('removes terminal control characters from cached Codex feature labels', async () => { + const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); + + const label = getCodexWindowDisplayLabel({ + label: 'ignored', + resetAfterSeconds: 3600, + category: 'additional', + cadence: 'weekly', + featureLabel: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007', + }); + + expect(label).toBe('Codex Spark (weekly)'); + expect(label).not.toContain('\u001b'); + expect(label).not.toContain('\u0007'); + }); +});