From a21aa1f53c71260d4d1f0b01ae3d4e5a3f09db5e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 8 May 2026 17:44:30 -0300 Subject: [PATCH] fix(sse): prevent Claude Code identity cloak overrides and fix fallback resilience (#2053) --- open-sse/executors/base.ts | 28 ++++-- open-sse/executors/claudeIdentity.ts | 52 ++++++++-- open-sse/services/usage.ts | 51 +++++++--- .../settings/components/RoutingTab.tsx | 41 ++++++-- .../usage/components/ProviderLimits/index.tsx | 38 +++++--- .../usage/components/ProviderLimits/utils.tsx | 26 +++++ src/i18n/messages/ar.json | 5 +- src/i18n/messages/bg.json | 5 +- src/i18n/messages/bn.json | 5 +- src/i18n/messages/cs.json | 5 +- src/i18n/messages/da.json | 5 +- src/i18n/messages/de.json | 5 +- src/i18n/messages/en.json | 5 +- src/i18n/messages/es.json | 5 +- src/i18n/messages/fa.json | 5 +- src/i18n/messages/fi.json | 5 +- src/i18n/messages/fr.json | 5 +- src/i18n/messages/gu.json | 5 +- src/i18n/messages/he.json | 5 +- src/i18n/messages/hi.json | 5 +- src/i18n/messages/hu.json | 5 +- src/i18n/messages/id.json | 5 +- src/i18n/messages/in.json | 5 +- src/i18n/messages/it.json | 5 +- src/i18n/messages/ja.json | 5 +- src/i18n/messages/ko.json | 5 +- src/i18n/messages/mr.json | 5 +- src/i18n/messages/ms.json | 5 +- src/i18n/messages/nl.json | 5 +- src/i18n/messages/no.json | 5 +- src/i18n/messages/phi.json | 5 +- src/i18n/messages/pl.json | 5 +- src/i18n/messages/pt-BR.json | 5 +- src/i18n/messages/pt.json | 5 +- src/i18n/messages/ro.json | 5 +- src/i18n/messages/ru.json | 5 +- src/i18n/messages/sk.json | 5 +- src/i18n/messages/sv.json | 5 +- src/i18n/messages/sw.json | 5 +- src/i18n/messages/ta.json | 5 +- src/i18n/messages/te.json | 5 +- src/i18n/messages/th.json | 5 +- src/i18n/messages/tr.json | 5 +- src/i18n/messages/uk-UA.json | 5 +- src/i18n/messages/ur.json | 5 +- src/i18n/messages/vi.json | 5 +- src/i18n/messages/zh-CN.json | 5 +- src/lib/usage/providerLimits.ts | 97 +++++++++++++++++-- 48 files changed, 435 insertions(+), 103 deletions(-) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 14c385695c..78988660df 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -614,26 +614,40 @@ export class BaseExecutor { | Record | undefined; - let identitySource: "upstream-metadata" | "upstream-header" | "synthesized" = - "synthesized"; + let identitySource: + | "upstream-metadata" + | "upstream-header" + | "synthesized" + | "synthesized-cloaked" = "synthesized"; let sessionId: string; let deviceId: string; let accountUUID: string; - const upstreamUserId = parseUpstreamMetadataUserId(tb); + // For any Claude OAuth request, ignore client-supplied metadata.user_id / + // X-Claude-Code-Session-Id and synthesize per-account: the CC device_id from + // ~/.claude.json is shared across every account on one machine, which lets + // Anthropic correlate accounts behind one OmniRoute. + const cloakIdentity = isClaudeCodeClient || hasClaudeOAuthToken; + const upstreamUserId = cloakIdentity ? null : parseUpstreamMetadataUserId(tb); if (upstreamUserId) { sessionId = upstreamUserId.session_id; deviceId = upstreamUserId.device_id; accountUUID = upstreamUserId.account_uuid; identitySource = "upstream-metadata"; } else { - const headerSid = passthroughUpstreamSessionId( - clientHeaders as Record | undefined - ); + const headerSid = cloakIdentity + ? null + : passthroughUpstreamSessionId( + clientHeaders as Record | undefined + ); sessionId = headerSid ?? getSessionId(seed); deviceId = resolveCliUserID(psd, seed); accountUUID = resolveAccountUUID(psd, seed, activeCredentials?.accessToken); - identitySource = headerSid ? "upstream-header" : "synthesized"; + identitySource = headerSid + ? "upstream-header" + : cloakIdentity + ? "synthesized-cloaked" + : "synthesized"; } // system[0] (billing) and system[1] (sentinel) must not carry diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index 698664d929..b6355b454b 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -132,12 +132,17 @@ const ACCOUNT_FETCH_RETRY_MS = 5 * 60 * 1000; const accountUuidCache = new Map(); const inflightFetches = new Set(); -async function backgroundFetchAccountUUID(accessToken: string, seed: string): Promise { - if (inflightFetches.has(seed)) return; - const cached = accountUuidCache.get(seed); - if (cached?.uuid) return; - if (cached && Date.now() - cached.fetchedAt < ACCOUNT_FETCH_RETRY_MS) return; - inflightFetches.add(seed); +export type ClaudeBootstrap = { + account_uuid: string | null; + account_email: string | null; + organization_uuid: string | null; + organization_name: string | null; + organization_type: string | null; + organization_rate_limit_tier: string | null; +}; + +/** GET /api/claude_cli/bootstrap with the same headers real CLI uses. */ +export async function fetchClaudeBootstrap(accessToken: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), BOOTSTRAP_FETCH_TIMEOUT_MS); try { @@ -151,13 +156,40 @@ async function backgroundFetchAccountUUID(accessToken: string, seed: string): Pr }, signal: ctrl.signal, }); - const data: any = res.ok ? await res.json().catch(() => null) : null; - const uuid: string | null = data?.oauth_account?.account_uuid || null; - setBounded(accountUuidCache, seed, { uuid, fetchedAt: Date.now() }, IDENTITY_CACHE_LIMIT); + if (!res.ok) return null; + const data: any = await res.json().catch(() => null); + const acct = data?.oauth_account; + if (!acct || typeof acct !== "object") return null; + return { + account_uuid: acct.account_uuid || null, + account_email: acct.account_email || null, + organization_uuid: acct.organization_uuid || null, + organization_name: acct.organization_name || null, + organization_type: acct.organization_type || null, + organization_rate_limit_tier: acct.organization_rate_limit_tier || null, + }; } catch { - setBounded(accountUuidCache, seed, { uuid: null, fetchedAt: Date.now() }, IDENTITY_CACHE_LIMIT); + return null; } finally { clearTimeout(timer); + } +} + +async function backgroundFetchAccountUUID(accessToken: string, seed: string): Promise { + if (inflightFetches.has(seed)) return; + const cached = accountUuidCache.get(seed); + if (cached?.uuid) return; + if (cached && Date.now() - cached.fetchedAt < ACCOUNT_FETCH_RETRY_MS) return; + inflightFetches.add(seed); + try { + const bootstrap = await fetchClaudeBootstrap(accessToken); + setBounded( + accountUuidCache, + seed, + { uuid: bootstrap?.account_uuid ?? null, fetchedAt: Date.now() }, + IDENTITY_CACHE_LIMIT + ); + } finally { inflightFetches.delete(seed); } } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index b94eea2d21..c5eb8424d6 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -26,6 +26,7 @@ import { updateAntigravityRemainingCredits, } from "../executors/antigravity.ts"; import { getCreditsMode } from "./antigravityCredits.ts"; +import { CLAUDE_CODE_VERSION, fetchClaudeBootstrap } from "../executors/claudeIdentity.ts"; import { deriveAntigravityMachineId, generateAntigravityRequestId, @@ -1719,17 +1720,30 @@ async function getAntigravitySubscriptionInfo(accessToken) { * Claude Usage - Try to fetch from Anthropic API */ async function getClaudeUsage(accessToken) { + // Refresh bootstrap in parallel; best-effort, failure non-fatal. + const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null); try { - // Primary: Try OAuth usage endpoint (works with Claude Code consumer OAuth tokens) - // Requires anthropic-beta: oauth-2025-04-20 header - const oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "anthropic-beta": "oauth-2025-04-20", - "anthropic-version": CLAUDE_CONFIG.apiVersion, - }, - }); + // Real CLI uses axios here, not Stainless — UA is `claude-code/` + // (not `claude-cli/...`) and the shape is simpler than /v1/messages. + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 10_000); + let oauthResponse; + try { + oauthResponse = await fetch(CLAUDE_CONFIG.oauthUsageUrl, { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + "Accept-Encoding": "gzip, compress, deflate, br", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": `claude-code/${CLAUDE_CODE_VERSION}`, + "anthropic-beta": "oauth-2025-04-20", + }, + signal: ctrl.signal, + }); + } finally { + clearTimeout(timer); + } if (oauthResponse.ok) { const data = await oauthResponse.json(); @@ -1761,11 +1775,15 @@ async function getClaudeUsage(accessToken) { quotas["weekly (7d)"] = createQuotaObject(data.seven_day); } - // Parse model-specific weekly windows (e.g., seven_day_sonnet, seven_day_opus) + // Map Anthropic's internal codenames (e.g., omelette → Designer) for display. + const MODEL_DISPLAY_NAMES: Record = { + omelette: "designer", + }; for (const [key, value] of Object.entries(data)) { const valueRecord = toRecord(value); if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(valueRecord)) { - const modelName = key.replace("seven_day_", ""); + const codename = key.replace("seven_day_", ""); + const modelName = MODEL_DISPLAY_NAMES[codename] || codename; quotas[`weekly ${modelName} (7d)`] = createQuotaObject(valueRecord); } } @@ -1784,6 +1802,7 @@ async function getClaudeUsage(accessToken) { plan: planRaw || "Claude Code", quotas, extraUsage: data.extra_usage ?? null, + bootstrap: await bootstrapPromise, }; } @@ -1791,9 +1810,13 @@ async function getClaudeUsage(accessToken) { console.warn( `[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy` ); - return await getClaudeUsageLegacy(accessToken); + const legacy = await getClaudeUsageLegacy(accessToken); + return { ...legacy, bootstrap: await bootstrapPromise }; } catch (error) { - return { message: `Claude connected. Unable to fetch usage: ${(error as Error).message}` }; + return { + message: `Claude connected. Unable to fetch usage: ${(error as Error).message}`, + bootstrap: await bootstrapPromise, + }; } } diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index f65e0cb132..7568bec217 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -49,7 +49,9 @@ export default function RoutingTab() { const cliCompatProviders = useMemo( () => Array.isArray(settings.cliCompatProviders) - ? settings.cliCompatProviders.map((providerId: string) => normalizeCliCompatProviderId(providerId)) + ? settings.cliCompatProviders.map((providerId: string) => + normalizeCliCompatProviderId(providerId) + ) : [], [settings.cliCompatProviders] ); @@ -270,33 +272,54 @@ export default function RoutingTab() { {CLI_COMPAT_TOGGLE_IDS.map((providerId) => { const normalizedProviderId = normalizeCliCompatProviderId(providerId); const providerDisplay = CLI_COMPAT_PROVIDER_DISPLAY[providerId]; - const checked = cliCompatProviderSet.has(normalizedProviderId); + // Claude OAuth force-applies the fingerprint regardless of this toggle + // (base.ts: shouldFingerprint), so render the tile as locked-on. + const forced = providerId === "claude"; + const checked = forced || cliCompatProviderSet.has(normalizedProviderId); const label = providerDisplay?.name || providerId; const description = providerDisplay?.description || providerId; + const titleText = forced + ? t("forcedFingerprintTitle", { provider: label }) + : checked + ? t("disableFingerprintTitle", { provider: label }) + : t("enableFingerprintTitle", { provider: label }); return (