From fb8a6314cc11102da03b882c804cf345df02dfb7 Mon Sep 17 00:00:00 2001 From: Tentoxa <53821604+Tentoxa@users.noreply.github.com> Date: Fri, 8 May 2026 08:40:34 +0200 Subject: [PATCH 1/2] fix(claude): cloak per-account identity to prevent multi-account correlation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple Claude OAuth accounts are routed through one OmniRoute, Anthropic could correlate them via the Claude Code metadata.user_id blob: - device_id from ~/.claude.json is shared across every account on one machine - account_uuid may not match the OAuth token actually being routed (active mismatch — stronger tell than just sharing) - session_id is shared across accounts when one CC process fans out via combo This forces per-OAuth-account identity synthesis whenever a Claude OAuth token is in use (isClaudeCodeClient || hasClaudeOAuthToken), so a non-CC client mimicking metadata.user_id against an OAuth token can't slip its identity through either. Also align /api/oauth/usage probe with real CLI shape (claude-code/ UA, Accept/Content-Type/Accept-Encoding match, 10s abort), and lock the Claude tile in Settings -> CLI Fingerprint as forced-on with a "Required" badge — the toggle was misleading because shouldFingerprint already forces fingerprinting on for OAuth regardless of the saved setting. --- open-sse/executors/base.ts | 28 +++++++++++---- open-sse/services/usage.ts | 32 +++++++++++------ .../settings/components/RoutingTab.tsx | 35 ++++++++++++++----- src/i18n/messages/ar.json | 2 ++ src/i18n/messages/bg.json | 2 ++ src/i18n/messages/bn.json | 2 ++ src/i18n/messages/cs.json | 2 ++ src/i18n/messages/da.json | 2 ++ src/i18n/messages/de.json | 2 ++ src/i18n/messages/en.json | 2 ++ src/i18n/messages/es.json | 2 ++ src/i18n/messages/fa.json | 2 ++ src/i18n/messages/fi.json | 2 ++ src/i18n/messages/fr.json | 2 ++ src/i18n/messages/gu.json | 2 ++ src/i18n/messages/he.json | 2 ++ src/i18n/messages/hi.json | 2 ++ src/i18n/messages/hu.json | 2 ++ src/i18n/messages/id.json | 2 ++ src/i18n/messages/in.json | 2 ++ src/i18n/messages/it.json | 2 ++ src/i18n/messages/ja.json | 2 ++ src/i18n/messages/ko.json | 2 ++ src/i18n/messages/mr.json | 2 ++ src/i18n/messages/ms.json | 2 ++ src/i18n/messages/nl.json | 2 ++ src/i18n/messages/no.json | 2 ++ src/i18n/messages/phi.json | 2 ++ src/i18n/messages/pl.json | 2 ++ src/i18n/messages/pt-BR.json | 2 ++ src/i18n/messages/pt.json | 2 ++ src/i18n/messages/ro.json | 2 ++ src/i18n/messages/ru.json | 2 ++ src/i18n/messages/sk.json | 2 ++ src/i18n/messages/sv.json | 2 ++ src/i18n/messages/sw.json | 2 ++ src/i18n/messages/ta.json | 2 ++ src/i18n/messages/te.json | 2 ++ src/i18n/messages/th.json | 2 ++ src/i18n/messages/tr.json | 2 ++ src/i18n/messages/uk-UA.json | 2 ++ src/i18n/messages/ur.json | 2 ++ src/i18n/messages/vi.json | 2 ++ src/i18n/messages/zh-CN.json | 2 ++ 44 files changed, 152 insertions(+), 25 deletions(-) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 39a6fabb69ed..32804f4cfb81 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -600,26 +600,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/services/usage.ts b/open-sse/services/usage.ts index 2bf2edec5f5c..fe20fb41448c 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -28,6 +28,7 @@ import { updateAntigravityRemainingCredits, } from "../executors/antigravity.ts"; import { getCreditsMode } from "./antigravityCredits.ts"; +import { CLAUDE_CODE_VERSION } from "../executors/claudeIdentity.ts"; // Antigravity API config (credentials from PROVIDERS via credential loader) const ANTIGRAVITY_CONFIG = { @@ -1711,16 +1712,27 @@ async function getAntigravitySubscriptionInfo(accessToken) { */ async function getClaudeUsage(accessToken) { 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(); diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index f65e0cb13263..6e3f1fc82b30 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -270,33 +270,52 @@ 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 (