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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user