History

fix(claude): avoid racy fingerprint downgrades

tpob committed 7 months ago

Commit 8ca4c201 parent ae1d7e92 files · +111 −4Browse files

Unified · Split

2 files
  1. internal/runtime/executor/claude_device_profile.go +18 −4
  2. internal/runtime/executor/claude_executor_test.go +93 −0
internal/runtime/executor/claude_device_profile.go+18−4
@@ -31,6 +31,8 @@ var ( · 30 unmodified lines
3131 claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry)
3232 claudeDeviceProfileCacheMu sync.RWMutex
3333 claudeDeviceProfileCacheCleanupOnce sync.Once
34+
35+ claudeDeviceProfileBeforeCandidateStore func(claudeDeviceProfile)
3436 )
3537
3638 type claudeCLIVersion struct {
@@ -257,13 +259,25 @@ func resolveClaudeDeviceProfile(auth *cliproxyauth.Auth, apiKey string, headers · 220 unmodified lines
257259 cachedValid := hasCached && entry.expire.After(now) && entry.profile.UserAgent != ""
258260 claudeDeviceProfileCacheMu.RUnlock()
259261
260− if hasCandidate && (!cachedValid || shouldUpgradeClaudeDeviceProfile(candidate, entry.profile)) {
261− newEntry := claudeDeviceProfileCacheEntry{
262+ if hasCandidate {
263+ if claudeDeviceProfileBeforeCandidateStore != nil {
264+ claudeDeviceProfileBeforeCandidateStore(candidate)
265+ }
266+
267+ claudeDeviceProfileCacheMu.Lock()
268+ entry, hasCached = claudeDeviceProfileCache[cacheKey]
269+ cachedValid = hasCached && entry.expire.After(now) && entry.profile.UserAgent != ""
270+ if cachedValid && !shouldUpgradeClaudeDeviceProfile(candidate, entry.profile) {
271+ entry.expire = now.Add(claudeDeviceProfileTTL)
272+ claudeDeviceProfileCache[cacheKey] = entry
273+ claudeDeviceProfileCacheMu.Unlock()
274+ return entry.profile
275+ }
276+
277+ claudeDeviceProfileCache[cacheKey] = claudeDeviceProfileCacheEntry{
262278 profile: candidate,
263279 expire: now.Add(claudeDeviceProfileTTL),
264280 }
265− claudeDeviceProfileCacheMu.Lock()
266− claudeDeviceProfileCache[cacheKey] = newEntry
267281 claudeDeviceProfileCacheMu.Unlock()
268282 return candidate
269283 }
internal/runtime/executor/claude_executor_test.go+93−0
@@ -8,7 +8,9 @@ import ( · 7 unmodified lines
88 "net/http"
99 "net/http/httptest"
1010 "strings"
11+ "sync"
1112 "testing"
13+ "time"
1214
1315 "github.com/gin-gonic/gin"
1416 "github.com/klauspost/compress/zstd"
@@ -206,6 +208,97 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien · 191 unmodified lines
206208 assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "Linux", "x64")
207209 }
208210
211+func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testing.T) {
212+ resetClaudeDeviceProfileCache()
213+ stabilize := true
214+
215+ cfg := &config.Config{
216+ ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
217+ UserAgent: "claude-cli/2.1.60 (external, cli)",
218+ PackageVersion: "0.70.0",
219+ RuntimeVersion: "v22.0.0",
220+ OS: "MacOS",
221+ Arch: "arm64",
222+ StabilizeDeviceProfile: &stabilize,
223+ },
224+ }
225+ auth := &cliproxyauth.Auth{
226+ ID: "auth-racy-upgrade",
227+ Attributes: map[string]string{
228+ "api_key": "key-racy-upgrade",
229+ },
230+ }
231+
232+ lowPaused := make(chan struct{})
233+ releaseLow := make(chan struct{})
234+ var pauseOnce sync.Once
235+ var releaseOnce sync.Once
236+
237+ claudeDeviceProfileBeforeCandidateStore = func(candidate claudeDeviceProfile) {
238+ if candidate.UserAgent != "claude-cli/2.1.62 (external, cli)" {
239+ return
240+ }
241+ pauseOnce.Do(func() { close(lowPaused) })
242+ <-releaseLow
243+ }
244+ t.Cleanup(func() {
245+ claudeDeviceProfileBeforeCandidateStore = nil
246+ releaseOnce.Do(func() { close(releaseLow) })
247+ })
248+
249+ lowResultCh := make(chan claudeDeviceProfile, 1)
250+ go func() {
251+ lowResultCh <- resolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{
252+ "User-Agent": []string{"claude-cli/2.1.62 (external, cli)"},
253+ "X-Stainless-Package-Version": []string{"0.74.0"},
254+ "X-Stainless-Runtime-Version": []string{"v24.3.0"},
255+ "X-Stainless-Os": []string{"Linux"},
256+ "X-Stainless-Arch": []string{"x64"},
257+ }, cfg)
258+ }()
259+
260+ select {
261+ case <-lowPaused:
262+ case <-time.After(2 * time.Second):
263+ t.Fatal("timed out waiting for lower candidate to pause before storing")
264+ }
265+
266+ highResult := resolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{
267+ "User-Agent": []string{"claude-cli/2.1.63 (external, cli)"},
268+ "X-Stainless-Package-Version": []string{"0.75.0"},
269+ "X-Stainless-Runtime-Version": []string{"v24.4.0"},
270+ "X-Stainless-Os": []string{"MacOS"},
271+ "X-Stainless-Arch": []string{"arm64"},
272+ }, cfg)
273+ releaseOnce.Do(func() { close(releaseLow) })
274+
275+ select {
276+ case lowResult := <-lowResultCh:
277+ if lowResult.UserAgent != "claude-cli/2.1.63 (external, cli)" {
278+ t.Fatalf("lowResult.UserAgent = %q, want %q", lowResult.UserAgent, "claude-cli/2.1.63 (external, cli)")
279+ }
280+ if lowResult.PackageVersion != "0.75.0" {
281+ t.Fatalf("lowResult.PackageVersion = %q, want %q", lowResult.PackageVersion, "0.75.0")
282+ }
283+ case <-time.After(2 * time.Second):
284+ t.Fatal("timed out waiting for lower candidate result")
285+ }
286+
287+ if highResult.UserAgent != "claude-cli/2.1.63 (external, cli)" {
288+ t.Fatalf("highResult.UserAgent = %q, want %q", highResult.UserAgent, "claude-cli/2.1.63 (external, cli)")
289+ }
290+
291+ cached := resolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{
292+ "User-Agent": []string{"curl/8.7.1"},
293+ }, cfg)
294+ if cached.UserAgent != "claude-cli/2.1.63 (external, cli)" {
295+ t.Fatalf("cached.UserAgent = %q, want %q", cached.UserAgent, "claude-cli/2.1.63 (external, cli)")
296+ }
297+ if cached.PackageVersion != "0.75.0" {
298+ t.Fatalf("cached.PackageVersion = %q, want %q", cached.PackageVersion, "0.75.0")
299+ }
300+}
301+
209302 func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) {
210303 resetClaudeDeviceProfileCache()
211304