Skip to content

feat(claude): stabilize device fingerprint across mixed Claude Code and cloaked clients#2213

Open
TTTPOB wants to merge 6 commits intorouter-for-me:mainfrom
TTTPOB:ua-fix
Open

feat(claude): stabilize device fingerprint across mixed Claude Code and cloaked clients#2213
TTTPOB wants to merge 6 commits intorouter-for-me:mainfrom
TTTPOB:ua-fix

Conversation

@TTTPOB
Copy link

@TTTPOB TTTPOB commented Mar 18, 2026

Summary

This draft PR adds an optional Claude device-profile stabilization feature.

When enabled, CLIProxyAPI learns and pins a single outgoing fingerprint per upstream identity, so mixed traffic from:

  • real Claude Code clients
  • cloaked non-Claude clients

does not cause User-Agent / X-Stainless-* downgrade flicker at Anthropic.

Motivation

Directly related external report:

That issue describes the same risk: one real Claude Code client upgrades the observed fingerprint, then a later cloaked request downgrades it back to an older/default one. The same upstream identity then appears to jump between client versions/environments, which may increase ban risk.

Related Upstream Work

This builds on earlier Claude cloaking / fingerprint work in this repo:

  • #868 native Claude cloaking
  • #1621 discussion of applyClaudeHeaders() defaults
  • #1628 configurable Claude header defaults
  • #1662 cache-user-id toggle
  • #1750 alignment with real Claude Code 2.1.63

What This PR Does

  • Treats these fields as one device-profile bundle:
    • User-Agent
    • X-Stainless-Package-Version
    • X-Stainless-Runtime-Version
    • X-Stainless-Os
    • X-Stainless-Arch
  • Caches that profile per upstream identity
    • prefer auth.ID
    • otherwise API-key scope
  • Updates the cached profile only when a higher claude-cli/<semver> version is observed
  • Reuses that resolved profile for all outgoing Anthropic requests when enabled
  • Extends claude-header-defaults with:
    • os
    • arch
    • stabilize-device-profile

Config

claude-header-defaults:
  user-agent: "claude-cli/2.1.63 (external, cli)"
  package-version: "0.74.0"
  runtime-version: "v24.3.0"
  os: "MacOS"
  arch: "arm64"
  timeout: "600"
  stabilize-device-profile: false

stabilize-device-profile defaults to false.

This is intentional: some personal/shared deployments may accept the risk of mixed fingerprints if all users are trusted. Operators must opt in explicitly to enable profile pinning.

Important Notes

  • Cold start:
    if no official Claude fingerprint has been observed yet for a scope, the configured baseline is used.
  • Same-version CLI / extension:
    if two official-looking clients report the same claude-cli/<semver> version, first observed wins. Equal-version replacements are ignored to avoid environment flicker.
  • Session semantics:
    this does not change the separation between device fingerprint and metadata.user_id / session behavior.

Security Concern

This draft exists because fingerprint learning itself has trust implications.

If a client sends:

  • a non-existent Claude Code version
  • an invalid combination of User-Agent and X-Stainless-*
  • a semantically unrealistic profile

then pinning that profile could itself increase ban risk.

Two possible hardening directions:

  1. require admin confirmation before upgrading to a newly observed version
  2. maintain a whitelist of allowed Claude versions / combinations

Persistence Concern

This implementation is currently in-memory only.

It does not persist learned device profiles across process restarts yet. That is partly because the trust policy is still unresolved: persisting an unverified learned fingerprint would make a bad profile survive restarts.

There is existing per-account persistence infrastructure for file-backed/OAuth auth records, but not a single ready-made persistence path for both:

  • auth-file/OAuth identities
  • config-only API-key identities

So persistence is better handled in a follow-up after the trust model is settled.

Tests

Added coverage for:

  • baseline fingerprint on cold start
  • upgrade-only profile replacement
  • no downgrade on lower versions
  • disabled mode fallback
  • config loading for os, arch, and stabilize-device-profile

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces an optional feature to stabilize the Claude device fingerprint for outgoing Anthropic API requests. By learning and pinning a single, consistent device profile per upstream identity, it aims to prevent 'fingerprint flicker' caused by mixed traffic from genuine Claude Code clients and cloaked non-Claude clients. This stabilization helps reduce the risk of upstream bans by presenting a consistent client identity to Anthropic, addressing a known issue where fluctuating User-Agent and X-Stainless-* headers could flag an account for suspicious activity.

Highlights

  • Device Profile Bundling: Grouped User-Agent, X-Stainless-Package-Version, X-Stainless-Runtime-Version, X-Stainless-Os, and X-Stainless-Arch into a single device-profile bundle for consistent identification.
  • Per-Identity Profile Caching: Implemented caching of the device profile per upstream identity, preferring auth.ID or falling back to API-key scope, to maintain a stable fingerprint.
  • Version-Based Profile Updates: Configured the cached profile to update only when a higher claude-cli/<semver> version is observed, preventing downgrades and ensuring the most up-to-date profile is used.
  • Configurable Stabilization: Introduced a new stabilize-device-profile option in claude-header-defaults (defaulting to false) to allow operators to explicitly opt-in to device profile pinning.
  • Integration into Header Application: Integrated the new device profile resolution and application logic into the applyClaudeHeaders function, conditionally applying stabilized headers or falling back to legacy behavior.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The pull request introduces a new feature for stabilizing Claude device fingerprints, which is well-implemented with clear separation of concerns and good test coverage. The changes correctly integrate new configuration options, update the header application logic, and provide robust tests for various scenarios, including baseline usage, version upgrades, and disabled mode. The new claude_device_profile.go file encapsulates the core logic effectively, and the config.go updates ensure proper handling of the new settings. Overall, the code maintains a high standard of quality and addresses the stated motivation effectively.

@TTTPOB TTTPOB marked this pull request as ready for review March 18, 2026 13:52
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0e337aeb9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +101 to +102
OS: hdrDefault(hd.OS, defaultClaudeFingerprintOS),
Arch: hdrDefault(hd.Arch, defaultClaudeFingerprintArch),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep legacy OS/arch fallback runtime-derived

When stabilize-device-profile is unset or false, the legacy path still calls defaultClaudeDeviceProfile(), so non-Claude clients that omit X-Stainless-Os/X-Stainless-Arch now advertise the hard-coded MacOS/arm64 pair from here. Before this patch applyClaudeHeaders() used mapStainlessOS()/mapStainlessArch(), so Linux and Windows deployments will silently change their outgoing fingerprint even though the new feature is supposed to be opt-in.

Useful? React with 👍 / 👎.

Comment on lines +284 to +286
if isClaudeCodeClient(clientUA) {
r.Header.Set("User-Agent", clientUA)
return

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve configured User-Agent overrides for real Claude clients

This branch now runs after util.ApplyCustomHeadersFromAttrs() and unconditionally rewrites User-Agent back to the incoming claude-cli/... value whenever the caller is Claude Code. That means a claude-api-key[].headers.User-Agent override no longer works for official clients when stabilization is disabled, even though per-key headers used to win before this refactor; any upstream routing or allowlisting that depends on the configured UA will stop working for those requests.

Useful? React with 👍 / 👎.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd64adbeeb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +244 to +250
if hasCandidate && (!cachedValid || candidate.Version.Compare(entry.profile.Version) > 0) {
newEntry := claudeDeviceProfileCacheEntry{
profile: candidate,
expire: now.Add(claudeDeviceProfileTTL),
}
claudeDeviceProfileCacheMu.Lock()
claudeDeviceProfileCache[cacheKey] = newEntry

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-check cached version under the write lock

resolveClaudeDeviceProfile decides whether to accept candidate from a read-lock snapshot and then writes it unconditionally after taking the write lock. If two requests for the same auth race on a cold cache (for example claude-cli/2.1.63 and claude-cli/2.1.62), both will see cachedValid == false, and the lower-version goroutine can arrive second and overwrite the newer profile. That makes the stabilized fingerprint nondeterministic under normal concurrent load and reintroduces the downgrade flicker this feature is supposed to prevent.

Useful? React with 👍 / 👎.

Comment on lines +236 to +245
baseline := defaultClaudeDeviceProfile(cfg)
candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg)

claudeDeviceProfileCacheMu.RLock()
entry, hasCached := claudeDeviceProfileCache[cacheKey]
cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != ""
claudeDeviceProfileCacheMu.RUnlock()

if hasCandidate && (!cachedValid || candidate.Version.Compare(entry.profile.Version) > 0) {
newEntry := claudeDeviceProfileCacheEntry{

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare first learned profile against the configured baseline

On a cold start, this branch stores any official-looking Claude client profile without checking whether it is older than the configured claude-header-defaults baseline. In a deployment that pins a newer baseline (for cloaked traffic) and still has some older Claude Code clients, the first older client request will downgrade the cached fingerprint from the configured version to its older claude-cli/... tuple, even though the new feature describes the config values as the seeded pinned baseline.

Useful? React with 👍 / 👎.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8179d5a8a4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +865 to +866
if stabilizeDeviceProfile {
applyClaudeDeviceProfileHeaders(r, deviceProfile)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve custom fingerprint headers when stabilization is enabled

When stabilize-device-profile is true and an auth entry sets header:User-Agent or header:X-Stainless-*, util.ApplyCustomHeadersFromAttrs() writes those values first but this branch immediately overwrites all five fingerprint headers from deviceProfile. internal/util/header_helpers.go explicitly documents custom headers as overriding built-in defaults, so enabling stabilization now silently breaks per-key routing or allowlisting setups that pin a specific Claude fingerprint via claude-api-key[].headers.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in fact I don't know how should this feature be designed. it must be one overriding another if both configs are enabled.

Comment on lines +252 to +254
candidate, hasCandidate := extractClaudeDeviceProfile(headers, cfg)
if hasCandidate && !shouldUpgradeClaudeDeviceProfile(candidate, baseline) {
hasCandidate = false

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let the first same-version Claude client replace the seeded baseline

On a cold cache, a real Claude client whose claude-cli/<semver> exactly matches the configured baseline is discarded here because shouldUpgradeClaudeDeviceProfile(candidate, baseline) only accepts strictly newer versions. In the common case where operators seed the current Claude release but the first real client runs a different OS/arch or package/runtime, the proxy never learns that official profile and keeps advertising the synthetic baseline until some later version appears.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since admin is the one who will be responsible for configuring a baseline, it would not be a problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant