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.
This commit is contained in:
can1357
2026-06-08 18:28:07 +02:00
parent cfd72ffb40
commit 522a7d0f5d
3 changed files with 94 additions and 22 deletions
+42 -8
View File
@@ -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<string, unknown> | undefined, key: string): string | undefined {
const value = metadata?.[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readAnthropicMetadataAccountId(metadata: Record<string, unknown> | 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<string, string> = {
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.
+43 -1
View File
@@ -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(
@@ -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);