From 522a7d0f5d3720ccce7c6b62e2e5104f91789430 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 17:09:00 +0200 Subject: [PATCH] fix(ai): scoped Claude device_id to install id and account - Derived device_id from both install id and account UUID via v2 hash domain. - Fell back to v1 install-only hash when no account UUID is present. - Threaded account UUID through Anthropic metadata user_id resolution. --- packages/ai/src/providers/anthropic.ts | 50 ++++++++++++++++--- packages/ai/test/anthropic-alignment.test.ts | 44 +++++++++++++++- .../coding-agent/src/session/agent-session.ts | 22 ++++---- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 3d9136fd9..8a007cfde 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -630,20 +630,48 @@ export function generateClaudeCloakingUserId(): string { return `user_${userHash}_account_${accountId}_session_${sessionId}`; } -function deriveClaudeDeviceIdFromInstallId(): string { - return nodeCrypto.createHash("sha256").update(`omp-claude-device-id-v1:${getInstallId()}`).digest("hex"); +const CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN = "omp-claude-device-id-v1:"; +const CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN = "omp-claude-device-id-v2"; + +export function deriveClaudeDeviceId(installId: string, accountId?: string): string { + const hash = nodeCrypto.createHash("sha256"); + if (accountId && accountId.length > 0) { + return hash.update(CLAUDE_DEVICE_ID_ACCOUNT_HASH_DOMAIN).update("\0").update(installId).update("\0").update(accountId).digest("hex"); + } + return hash.update(CLAUDE_DEVICE_ID_INSTALL_HASH_DOMAIN).update(installId).digest("hex"); } -function generateClaudeJsonUserId(sessionId?: string): string { - return JSON.stringify({ - device_id: deriveClaudeDeviceIdFromInstallId(), + +function readMetadataString(metadata: Record | undefined, key: string): string | undefined { + const value = metadata?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readAnthropicMetadataAccountId(metadata: Record | undefined): string | undefined { + return ( + readMetadataString(metadata, "account_uuid") ?? + readMetadataString(metadata, "accountId") ?? + readMetadataString(metadata, "account_id") + ); +} + +function deriveClaudeDeviceIdFromInstallId(accountId?: string): string { + return deriveClaudeDeviceId(getInstallId(), accountId); +} + +function generateClaudeJsonUserId(sessionId?: string, accountId?: string): string { + const userId: Record = { + device_id: deriveClaudeDeviceIdFromInstallId(accountId), session_id: sessionId ?? nodeCrypto.randomUUID().toLowerCase(), - }); + }; + if (accountId && accountId.length > 0) userId.account_uuid = accountId; + return JSON.stringify(userId); } function resolveAnthropicMetadataUserId( userId: unknown, isOAuthToken: boolean, sessionId?: string, + accountId?: string, ): string | undefined { if (typeof userId === "string") { if (!isOAuthToken || isClaudeCloakingUserId(userId) || isClaudeJsonUserId(userId)) { @@ -652,7 +680,7 @@ function resolveAnthropicMetadataUserId( } if (!isOAuthToken) return undefined; - return generateClaudeJsonUserId(sessionId); + return generateClaudeJsonUserId(sessionId, accountId); } const ANTHROPIC_BUILTIN_TOOL_NAMES = new Set(["web_search", "code_execution", "text_editor", "computer"]); export const applyClaudeToolPrefix = (name: string): string => { @@ -2503,7 +2531,13 @@ function buildParams( } // Pre-compute metadata. - const metadataUserId = resolveAnthropicMetadataUserId(options?.metadata?.user_id, isOAuthToken, options?.sessionId); + const metadataAccountId = readAnthropicMetadataAccountId(options?.metadata); + const metadataUserId = resolveAnthropicMetadataUserId( + options?.metadata?.user_id, + isOAuthToken, + options?.sessionId, + metadataAccountId, + ); const metadata = metadataUserId ? { user_id: metadataUserId } : undefined; // Pre-compute thinking + output_config effort. diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 7ed9fddf2..5c4ee08a6 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -13,6 +13,7 @@ import { claudeCodeSystemInstruction, claudeCodeVersion, claudeToolPrefix, + deriveClaudeDeviceId, generateClaudeCloakingUserId, isClaudeCloakingUserId, mapStainlessArch, @@ -54,7 +55,7 @@ function createAbortedSignal(): AbortSignal { type CaptureAnthropicOptions = { isOAuth?: boolean; - metadata?: { user_id?: string }; + metadata?: { user_id?: string; account_uuid?: string; accountId?: string; account_id?: string }; thinkingEnabled?: boolean; reasoning?: Effort; temperature?: number; @@ -422,6 +423,18 @@ describe("Anthropic request fingerprint alignment", () => { expect(isClaudeCloakingUserId(userId)).toBe(true); }); + it("scopes derived Claude device IDs to the account when known", () => { + const installId = "test-install-id"; + const accountId = "12345678-1234-1234-1234-1234567890ab"; + const otherAccountId = "abcdefab-cdef-abcd-efab-cdefabcdef12"; + const deviceId = deriveClaudeDeviceId(installId, accountId); + + expect(deviceId).toMatch(/^[0-9a-f]{64}$/); + expect(deviceId).toBe(deriveClaudeDeviceId(installId, accountId)); + expect(deviceId).not.toBe(deriveClaudeDeviceId(installId, otherAccountId)); + expect(deviceId).not.toBe(deriveClaudeDeviceId(installId)); + }); + it("injects Claude Code JSON metadata.user_id for OAuth requests when missing", async () => { const payload = (await captureAnthropicPayload(ANTHROPIC_MODEL, { systemPrompt: ["Stay concise."], @@ -453,6 +466,35 @@ describe("Anthropic request fingerprint alignment", () => { expect(firstUserId.device_id).toBe(secondUserId.device_id); }); + it("uses metadata account_uuid when generating OAuth device_id", async () => { + const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; + const accountId = "12345678-1234-1234-1234-1234567890ab"; + const otherAccountId = "abcdefab-cdef-abcd-efab-cdefabcdef12"; + const first = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], + }, + { metadata: { account_uuid: accountId }, sessionId }, + )) as { metadata?: { user_id?: string } }; + const second = (await captureAnthropicPayload( + ANTHROPIC_MODEL, + { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi again", timestamp: Date.now() }], + }, + { metadata: { account_uuid: otherAccountId }, sessionId }, + )) as { metadata?: { user_id?: string } }; + const firstUserId = JSON.parse(first.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; + const secondUserId = JSON.parse(second.metadata?.user_id ?? "{}") as { account_uuid?: string; device_id?: string }; + + expect(firstUserId.account_uuid).toBe(accountId); + expect(secondUserId.account_uuid).toBe(otherAccountId); + expect(firstUserId.device_id).toMatch(/^[0-9a-f]{64}$/); + expect(firstUserId.device_id).not.toBe(secondUserId.device_id); + }); + it("uses the explicit session id for generated OAuth metadata", async () => { const sessionId = "167ec5b4-e711-4169-879f-84fa52679d9c"; const payload = (await captureAnthropicPayload( diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7c52203c2..69d75c53b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -13,7 +13,6 @@ * Modes use this class and add their own I/O layer on top. */ -import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -78,6 +77,7 @@ import type { import { calculateRateLimitBackoffMs, clearAnthropicFastModeFallback, + deriveClaudeDeviceId, Effort, getSupportedEfforts, isContextOverflow, @@ -606,14 +606,10 @@ function buildSessionMetadata( const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId); if (typeof accountUuid === "string" && accountUuid.length > 0) { userId.account_uuid = accountUuid; - // Claude Code's `device_id` is a stable 64-hex install identifier. Use - // omp's persistent install id as the root instead of deriving it from - // `account_uuid`: logging into a different Claude account on the same - // install should not make the device look new. - userId.device_id = crypto - .createHash("sha256") - .update(`omp-claude-device-id-v1:${getInstallId()}`) - .digest("hex"); + // Claude Code's `device_id` is a stable 64-hex account-scoped install + // identifier. Include both omp's persistent install id and the Claude + // account UUID so two accounts on the same install do not share a device. + userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid); } } return { user_id: JSON.stringify(userId) }; @@ -3024,10 +3020,10 @@ export class AgentSession { * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output: * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only * when an Anthropic OAuth credential with a known account UUID is loaded; - * `device_id` is derived from the persistent omp install id. Resolving live - * keeps the value in sync with auth-state changes (login/logout, token - * refresh that surfaces a new account uuid) without needing to re-call - * `#syncAgentSessionId()` on every such event. + * `device_id` is derived from both the persistent omp install id and that + * account UUID. Resolving live keeps the value in sync with auth-state changes + * (login/logout, token refresh that surfaces a new account UUID) without + * needing to re-call `#syncAgentSessionId()` on every such event. */ #syncAgentSessionId(sessionId?: string): void { const sid = this.#activeProviderSessionId(sessionId);