diff --git a/internal/auth/claude/identity.go b/internal/auth/claude/identity.go index df41b99c..3e4bde72 100644 --- a/internal/auth/claude/identity.go +++ b/internal/auth/claude/identity.go @@ -135,8 +135,9 @@ func EnsureDeviceIDPoolFor(metadata *map[string]any) ([]string, bool, error) { return ensureDeviceIDPoolLocked(*metadata) } -// ReadDeviceIDPool returns the raw stored pool value, initializing the map when -// needed, under the device pool lock. +// ReadDeviceIDPool returns the stored pool value, initializing the map when +// needed, under the device pool lock. Slice values are copied so a caller can +// never mutate the stored credential identity after the lock is released. func ReadDeviceIDPool(metadata *map[string]any) any { if metadata == nil { return nil @@ -148,7 +149,14 @@ func ReadDeviceIDPool(metadata *map[string]any) any { *metadata = make(map[string]any) return nil } - return (*metadata)[ClaudeDeviceIDsMetadataKey] + switch stored := (*metadata)[ClaudeDeviceIDsMetadataKey].(type) { + case []string: + return append([]string(nil), stored...) + case []any: + return append([]any(nil), stored...) + default: + return stored + } } // StoreDeviceIDPool writes a defensive copy of deviceIDs under the device pool lock. diff --git a/internal/auth/claude/identity_test.go b/internal/auth/claude/identity_test.go index ba224312..ea22a816 100644 --- a/internal/auth/claude/identity_test.go +++ b/internal/auth/claude/identity_test.go @@ -26,6 +26,44 @@ func TestGenerateDeviceIDPool(t *testing.T) { } } +// TestReadDeviceIDPoolReturnsDefensiveCopy pins that neither side of the device +// pool accessors hands out the live stored slice. A caller mutating a result must +// never be able to rewrite credential identity outside the device pool lock. +func TestReadDeviceIDPoolReturnsDefensiveCopy(t *testing.T) { + metadata := map[string]any{} + input := []string{"device-a", "device-b", "device-c"} + StoreDeviceIDPool(&metadata, input) + + // Write side: mutating the caller's input must not affect stored state. + input[0] = "mutated-input" + stored, ok := ReadDeviceIDPool(&metadata).([]string) + if !ok { + t.Fatalf("ReadDeviceIDPool() type = %T, want []string", ReadDeviceIDPool(&metadata)) + } + if stored[0] != "device-a" { + t.Fatalf("stored[0] = %q, want %q; write side is not defensive", stored[0], "device-a") + } + + // Read side: mutating the returned slice must not affect stored state. + stored[0] = "hijacked-device-id" + reread, _ := ReadDeviceIDPool(&metadata).([]string) + if reread[0] != "device-a" { + t.Fatalf("stored[0] = %q after mutating the read result, want %q", reread[0], "device-a") + } + + // A []any pool (as produced by JSON unmarshalling) must be copied too. + jsonMetadata := map[string]any{ClaudeDeviceIDsMetadataKey: []any{"json-a", "json-b"}} + jsonStored, ok := ReadDeviceIDPool(&jsonMetadata).([]any) + if !ok { + t.Fatalf("ReadDeviceIDPool() type = %T, want []any", ReadDeviceIDPool(&jsonMetadata)) + } + jsonStored[0] = "hijacked" + jsonReread, _ := ReadDeviceIDPool(&jsonMetadata).([]any) + if jsonReread[0] != "json-a" { + t.Fatalf("stored[0] = %v after mutating the read result, want %q", jsonReread[0], "json-a") + } +} + func TestEnsureDeviceIDPoolRepairsAndStabilizesCredentialMetadata(t *testing.T) { const first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" metadata := map[string]any{ diff --git a/internal/runtime/executor/claude_executor_auth.go b/internal/runtime/executor/claude_executor_auth.go index 5302aa4d..7a1dd9f9 100644 --- a/internal/runtime/executor/claude_executor_auth.go +++ b/internal/runtime/executor/claude_executor_auth.go @@ -14,7 +14,6 @@ import ( const ( claudeAccountProfileCheckedAtKey = "claude_account_profile_checked_at" - claudeAccountProfileRefreshAge = 24 * time.Hour claudeAccountProfileTimeout = 10 * time.Second ) @@ -28,22 +27,7 @@ func (e *ClaudeExecutor) ShouldPrepareRequestAuth(auth *cliproxyauth.Auth) bool if !claudeauth.HasCanonicalDeviceIDPool(claudeauth.ReadDeviceIDPool(&auth.Metadata)) { return true } - if helps.ClaudeCredentialAccountUUID(auth) != "" { - return false - } - return claudeAccountProfileLookupDue(claudeauth.ReadMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey), time.Now()) -} - -// claudeAccountProfileLookupDue takes the already-read timestamp rather than the -// metadata map: the map belongs to a credential shared by concurrent requests and -// may only be touched under the metadata lock. -func claudeAccountProfileLookupDue(checkedAt string, now time.Time) bool { - checkedAt = strings.TrimSpace(checkedAt) - if checkedAt == "" { - return true - } - parsed, errParse := time.Parse(time.RFC3339, checkedAt) - return errParse != nil || !parsed.Add(claudeAccountProfileRefreshAge).After(now) + return helps.ClaudeCredentialAccountUUID(auth) == "" } func (e *ClaudeExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { @@ -55,27 +39,25 @@ func (e *ClaudeExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxya if _, errDeviceIDs := helps.EnsureClaudeCredentialDevicePoolRequired(ctx, auth); errDeviceIDs != nil { return nil, errDeviceIDs } - if helps.ClaudeCredentialAccountUUID(auth) != "" || - !claudeAccountProfileLookupDue(claudeauth.ReadMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey), time.Now()) { + if helps.ClaudeCredentialAccountUUID(auth) != "" { return auth, nil } - claudeauth.StoreMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey, time.Now().UTC().Format(time.RFC3339)) profile, errProfile := e.fetchClaudeOAuthProfile(ctx, auth, apiKey) if errProfile != nil { if errContext := ctx.Err(); errContext != nil { return nil, errContext } - log.WithError(errProfile).Warn("claude executor: unable to populate OAuth account profile") - return auth, nil + return nil, fmt.Errorf("populate Claude OAuth account profile: %w", errProfile) } - if profile == nil { - return auth, nil + if profile == nil || strings.TrimSpace(profile.Account.UUID) == "" { + return nil, fmt.Errorf("populate Claude OAuth account profile: account UUID is empty") } claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", profile.Account.UUID) claudeauth.StoreMetadataString(&auth.Metadata, "email", profile.Account.Email) claudeauth.StoreMetadataString(&auth.Metadata, "organization_uuid", profile.Organization.UUID) claudeauth.StoreMetadataString(&auth.Metadata, "organization_name", profile.Organization.Name) + claudeauth.StoreMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey, time.Now().UTC().Format(time.RFC3339)) return auth, nil } diff --git a/internal/runtime/executor/claude_executor_auth_test.go b/internal/runtime/executor/claude_executor_auth_test.go index 44d2ca1d..f72ddb8a 100644 --- a/internal/runtime/executor/claude_executor_auth_test.go +++ b/internal/runtime/executor/claude_executor_auth_test.go @@ -90,30 +90,38 @@ func TestClaudeExecutorPrepareRequestAuthMigratesFiveDevicesToOne(t *testing.T) } } -func TestClaudeExecutorPrepareRequestAuthThrottlesFailedProfileLookup(t *testing.T) { +func TestClaudeExecutorPrepareRequestAuthIgnoresFreshTimestampWithoutIdentity(t *testing.T) { calls := 0 executor := NewClaudeExecutor(&config.Config{}) executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) { calls++ return nil, fmt.Errorf("profile unavailable") } + const previousCheckedAt = "2999-01-01T00:00:00Z" auth := &cliproxyauth.Auth{ ID: "claude-profile-unavailable", Attributes: map[string]string{"api_key": "sk-ant-oat-profile-unavailable"}, - Metadata: map[string]any{"type": "claude"}, + Metadata: map[string]any{ + "type": "claude", + claudeAccountProfileCheckedAtKey: previousCheckedAt, + claudeauth.ClaudeDeviceIDsMetadataKey: []string{"0000000000000000000000000000000000000000000000000000000000000000"}, + }, } prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth) - if errPrepare != nil { - t.Fatalf("PrepareRequestAuth() error = %v", errPrepare) + if errPrepare == nil { + t.Fatal("PrepareRequestAuth() error = nil, want missing account identity failure") + } + if prepared != nil { + t.Fatalf("PrepareRequestAuth() auth = %#v, want nil on missing account identity", prepared) } if calls != 1 { t.Fatalf("profile calls = %d, want 1", calls) } - if len(claudeauth.NormalizeDeviceIDPool(prepared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey])) != claudeauth.ClaudeDevicePoolSize { - t.Fatal("device pool was not populated after profile failure") + if !executor.ShouldPrepareRequestAuth(auth) { + t.Fatal("ShouldPrepareRequestAuth() = false after failed profile lookup; failure must remain retryable") } - if executor.ShouldPrepareRequestAuth(prepared) { - t.Fatal("ShouldPrepareRequestAuth() = true immediately after failed profile lookup") + if got := claudeauth.ReadMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey); got != previousCheckedAt { + t.Fatalf("profile checked timestamp = %q, want prior value preserved without suppressing retry", got) } } diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go index 0ceb971b..cd684980 100644 --- a/internal/runtime/executor/claude_executor_beta_policy_test.go +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -143,6 +143,7 @@ func TestClaudeExecutor_ContextManagementNeverLeaksToOtherUpstreams(t *testing.T auth := &cliproxyauth.Auth{ ID: "claude-non-anthropic-upstream", Attributes: map[string]string{"api_key": "sk-ant-oat-non-anthropic", "base_url": server.URL}, + Metadata: claudeOAuthTestMetadata(), } payload := []byte(`{"model":"claude-opus-5","system":"p","messages":[{"role":"user","content":"hi"}]}`) @@ -161,6 +162,9 @@ func TestIsAnthropicUpstreamBase(t *testing.T) { cases := map[string]bool{ "https://api.anthropic.com": true, "https://API.Anthropic.com": true, + "https://api.anthropic.com:443": true, + "https://api.anthropic.com:8443": false, + "https://user@api.anthropic.com": false, "https://api.kimi.com": false, "http://api.anthropic.com": false, "https://api.anthropic.com.evil": false, diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index c1d01b76..cf995a50 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -35,9 +35,9 @@ func resolveIncomingClaudeHeaders(ctx context.Context, incoming http.Header) htt return resolved } -func detectIncomingClaudeCodeRequest(ctx context.Context, incoming http.Header, payload []byte, countTokens bool) (http.Header, helps.ClaudeCodeRequestDetection) { +func detectIncomingClaudeCodeRequest(ctx context.Context, incoming http.Header, payload []byte, countTokens bool, cfg *config.Config) (http.Header, helps.ClaudeCodeRequestDetection) { resolved := resolveIncomingClaudeHeaders(ctx, incoming) - return resolved, helps.DetectClaudeCodeRequest(resolved, payload, countTokens) + return resolved, helps.DetectClaudeCodeRequest(resolved, payload, countTokens, cfg) } // getWorkloadFromContext extracts workload identifier from the gin request headers. diff --git a/internal/runtime/executor/claude_executor_diagnostics.go b/internal/runtime/executor/claude_executor_diagnostics.go index 183b41a6..cc81ccb1 100644 --- a/internal/runtime/executor/claude_executor_diagnostics.go +++ b/internal/runtime/executor/claude_executor_diagnostics.go @@ -4,7 +4,9 @@ import ( "bytes" "strings" + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -14,8 +16,8 @@ type claudeDiagnosticsRequestState struct { sequence uint64 } -func injectClaudeDiagnostics(body []byte, apiKey, sessionID string) ([]byte, claudeDiagnosticsRequestState) { - key, sequence, previousMessageID := helps.BeginClaudeDiagnostics(apiKey, sessionID) +func injectClaudeDiagnostics(body []byte, auth *cliproxyauth.Auth, sessionID string) ([]byte, claudeDiagnosticsRequestState) { + key, sequence, previousMessageID := helps.BeginClaudeDiagnostics(claudeDiagnosticsCredentialIdentity(auth), sessionID) if key == "" { return body, claudeDiagnosticsRequestState{} } @@ -49,6 +51,26 @@ func injectClaudeDiagnostics(body []byte, apiKey, sessionID string) ([]byte, cla return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} } +func claudeDiagnosticsCredentialIdentity(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + if id := strings.TrimSpace(auth.ID); id != "" { + return "id:" + id + } + if index := strings.TrimSpace(auth.Index); index != "" { + return "index:" + index + } + deviceIDs := claudeauth.NormalizeDeviceIDPool(claudeauth.ReadDeviceIDPool(&auth.Metadata)) + if len(deviceIDs) > 0 { + return "device:" + deviceIDs[0] + } + if accountUUID := helps.ClaudeCredentialAccountUUID(auth); accountUUID != "" { + return "account:" + accountUUID + } + return "" +} + func commitClaudeDiagnostics(state claudeDiagnosticsRequestState, messageID string) { helps.CommitClaudeDiagnostics(state.key, state.sequence, messageID) } diff --git a/internal/runtime/executor/claude_executor_diagnostics_test.go b/internal/runtime/executor/claude_executor_diagnostics_test.go index a4e10bf7..92995e55 100644 --- a/internal/runtime/executor/claude_executor_diagnostics_test.go +++ b/internal/runtime/executor/claude_executor_diagnostics_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/google/uuid" claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -20,7 +21,9 @@ func TestInjectClaudeDiagnosticsMatchesNativeFieldOrderAndContinuity(t *testing. t.Parallel() body := []byte(`{"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"max_tokens":1,"messages":[]}`) - first, state := injectClaudeDiagnostics(body, "credential-diagnostics-order", "session-diagnostics-order") + testID := uuid.NewString() + auth := &cliproxyauth.Auth{ID: "credential-diagnostics-order-" + testID} + first, state := injectClaudeDiagnostics(body, auth, "session-diagnostics-order-"+testID) wantOrder := `"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"diagnostics":{"previous_message_id":null},"max_tokens"` if !bytes.Contains(first, []byte(wantOrder)) { t.Fatalf("diagnostics field order differs from native: %s", first) @@ -30,7 +33,7 @@ func TestInjectClaudeDiagnosticsMatchesNativeFieldOrderAndContinuity(t *testing. } commitClaudeDiagnostics(state, "msg_01ABCDEF0123456789ABCDEFG") - second, _ := injectClaudeDiagnostics(body, "credential-diagnostics-order", "session-diagnostics-order") + second, _ := injectClaudeDiagnostics(body, auth, "session-diagnostics-order-"+testID) if got := gjson.GetBytes(second, "diagnostics.previous_message_id").String(); got != "msg_01ABCDEF0123456789ABCDEFG" { t.Fatalf("second previous_message_id = %q, want committed upstream ID", got) } @@ -51,8 +54,9 @@ func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) }) ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) deviceIDs := []string{"0000000000000000000000000000000000000000000000000000000000000000"} + testID := uuid.NewString() auth := &cliproxyauth.Auth{ - ID: "diagnostics-live-path", + ID: "diagnostics-live-path-" + testID, Attributes: map[string]string{"api_key": "sk-ant-oat-diagnostics-live-path"}, Metadata: map[string]any{ "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", @@ -63,12 +67,15 @@ func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) request := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"x"}],"max_tokens":16}`)} options := cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatClaude, - Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "diagnostics-conversation"}, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "diagnostics-conversation-" + testID}, } - for range 2 { + for turn := range 2 { if _, errExecute := executor.Execute(ctx, auth, request, options); errExecute != nil { t.Fatalf("Execute() error = %v", errExecute) } + if turn == 0 { + auth.Attributes["api_key"] = "sk-ant-oat-diagnostics-live-path-rotated" + } } if len(previousValues) != 2 || previousValues[0].Type != gjson.Null || previousValues[0].Raw != "null" { t.Fatalf("first diagnostics value = %#v, want explicit null", previousValues) diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 4c082412..962eaa8d 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -43,11 +43,11 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false) + incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg) confirmedClaudeCode := claudeCodeDetection.Confirmed claudeSessionID := "" if oauthToken { - claudeSessionID = helps.ClaudeAgentSessionUUID(incomingHeaders, originalPayload, req.Payload, opts.Metadata, req.Metadata) + claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, upstreamStream) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, upstreamStream) @@ -82,7 +82,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if cloaked && isAnthropicUpstreamBase(baseURL) { body = injectClaudeCodeContextManagement(body) if oauthToken { - body, diagnosticsState = injectClaudeDiagnostics(body, apiKey, claudeSessionID) + body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) } } diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 2f8a754e..cabdadf3 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -307,7 +307,11 @@ func claudeRequestedBetas(incomingBetas string, extraBetas []string) map[string] // gateways set their own host, yet both delegate to ClaudeExecutor and are // therefore cloaked; a cloak-keyed rule silently rewrites their traffic too. func isAnthropicUpstreamURL(u *url.URL) bool { - return u != nil && strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "api.anthropic.com") + if u == nil || u.User != nil || !strings.EqualFold(u.Scheme, "https") || !strings.EqualFold(u.Hostname(), "api.anthropic.com") { + return false + } + port := u.Port() + return port == "" || port == "443" } // isAnthropicUpstreamBase reports whether a configured base URL targets Anthropic's diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 4507e9d7..4b060ea5 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -43,11 +43,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false) + incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, false, e.cfg) confirmedClaudeCode := claudeCodeDetection.Confirmed claudeSessionID := "" if oauthToken { - claudeSessionID = helps.ClaudeAgentSessionUUID(incomingHeaders, originalPayload, req.Payload, opts.Metadata, req.Metadata) + claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true) @@ -82,7 +82,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if cloaked && isAnthropicUpstreamBase(baseURL) { body = injectClaudeCodeContextManagement(body) if oauthToken { - body, diagnosticsState = injectClaudeDiagnostics(body, apiKey, claudeSessionID) + body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) } } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index e08bef60..3fb77b9a 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -33,6 +33,15 @@ func resetClaudeDeviceProfileCache() { helps.ResetClaudeDeviceProfileCache() } +func claudeOAuthTestMetadata() map[string]any { + return map[string]any{ + "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + claudeauth.ClaudeDeviceIDsMetadataKey: []string{ + "0000000000000000000000000000000000000000000000000000000000000000", + }, + } +} + func malformedClaudeTreeSignatureForClaudeExecutorTest() string { return base64.StdEncoding.EncodeToString([]byte{0x12, 0xFF, 0xFE, 0xFD}) } @@ -206,7 +215,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { } } -func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { +func TestApplyClaudeHeaders_RejectsUnmeasuredClaudeCLIFingerprints(t *testing.T) { resetClaudeDeviceProfileCache() stabilize := true @@ -235,7 +244,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "MacOS", "arm64") + assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"lobe-chat/1.0"}, @@ -255,7 +264,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Arch": []string{"arm64"}, }) applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, higherReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") + assertClaudeFingerprint(t, higherReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64") lowerReq := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"claude-cli/2.1.61 (external, cli)"}, @@ -265,7 +274,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) { "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64") + assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64") } func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClient(t *testing.T) { @@ -307,7 +316,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") + assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") } func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvances(t *testing.T) { @@ -349,7 +358,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, nil, oldCfg, nil, true) - assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64") + assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"curl/8.7.1"}, @@ -401,7 +410,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") + assertClaudeFingerprint(t, officialReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64") postLearningThirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"curl/8.7.1"}, @@ -441,11 +450,17 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi var releaseOnce sync.Once helps.ClaudeDeviceProfileBeforeCandidateStore = func(candidate helps.ClaudeDeviceProfile) { - if candidate.UserAgent != "claude-cli/2.1.62 (external, cli)" { + if candidate.UserAgent != "claude-cli/2.1.60 (external, cli)" { return } - pauseOnce.Do(func() { close(lowPaused) }) - <-releaseLow + pause := false + pauseOnce.Do(func() { + pause = true + close(lowPaused) + }) + if pause { + <-releaseLow + } } t.Cleanup(func() { helps.ClaudeDeviceProfileBeforeCandidateStore = nil @@ -455,9 +470,9 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi lowResultCh := make(chan helps.ClaudeDeviceProfile, 1) go func() { lowResultCh <- helps.ResolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{ - "User-Agent": []string{"claude-cli/2.1.62 (external, cli)"}, - "X-Stainless-Package-Version": []string{"0.74.0"}, - "X-Stainless-Runtime-Version": []string{"v24.3.0"}, + "User-Agent": []string{"claude-cli/2.1.60 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.70.0"}, + "X-Stainless-Runtime-Version": []string{"v22.0.0"}, "X-Stainless-Os": []string{"Linux"}, "X-Stainless-Arch": []string{"x64"}, }, cfg) @@ -470,9 +485,9 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi } highResult := helps.ResolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{ - "User-Agent": []string{"claude-cli/2.1.63 (external, cli)"}, - "X-Stainless-Package-Version": []string{"0.75.0"}, - "X-Stainless-Runtime-Version": []string{"v24.4.0"}, + "User-Agent": []string{"claude-cli/2.1.60 (external, cli)"}, + "X-Stainless-Package-Version": []string{"0.70.0"}, + "X-Stainless-Runtime-Version": []string{"v22.0.0"}, "X-Stainless-Os": []string{"MacOS"}, "X-Stainless-Arch": []string{"arm64"}, }, cfg) @@ -480,11 +495,11 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi select { case lowResult := <-lowResultCh: - if lowResult.UserAgent != "claude-cli/2.1.63 (external, cli)" { - t.Fatalf("lowResult.UserAgent = %q, want %q", lowResult.UserAgent, "claude-cli/2.1.63 (external, cli)") + if lowResult.UserAgent != "claude-cli/2.1.60 (external, cli)" { + t.Fatalf("lowResult.UserAgent = %q, want %q", lowResult.UserAgent, "claude-cli/2.1.60 (external, cli)") } - if lowResult.PackageVersion != "0.75.0" { - t.Fatalf("lowResult.PackageVersion = %q, want %q", lowResult.PackageVersion, "0.75.0") + if lowResult.PackageVersion != "0.70.0" { + t.Fatalf("lowResult.PackageVersion = %q, want %q", lowResult.PackageVersion, "0.70.0") } if lowResult.OS != "MacOS" || lowResult.Arch != "arm64" { t.Fatalf("lowResult platform = %s/%s, want %s/%s", lowResult.OS, lowResult.Arch, "MacOS", "arm64") @@ -493,8 +508,8 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi t.Fatal("timed out waiting for lower candidate result") } - if highResult.UserAgent != "claude-cli/2.1.63 (external, cli)" { - t.Fatalf("highResult.UserAgent = %q, want %q", highResult.UserAgent, "claude-cli/2.1.63 (external, cli)") + if highResult.UserAgent != "claude-cli/2.1.60 (external, cli)" { + t.Fatalf("highResult.UserAgent = %q, want %q", highResult.UserAgent, "claude-cli/2.1.60 (external, cli)") } if highResult.OS != "MacOS" || highResult.Arch != "arm64" { t.Fatalf("highResult platform = %s/%s, want %s/%s", highResult.OS, highResult.Arch, "MacOS", "arm64") @@ -503,11 +518,11 @@ func TestResolveClaudeDeviceProfile_RechecksCacheBeforeStoringCandidate(t *testi cached := helps.ResolveClaudeDeviceProfile(auth, "key-racy-upgrade", http.Header{ "User-Agent": []string{"curl/8.7.1"}, }, cfg) - if cached.UserAgent != "claude-cli/2.1.63 (external, cli)" { - t.Fatalf("cached.UserAgent = %q, want %q", cached.UserAgent, "claude-cli/2.1.63 (external, cli)") + if cached.UserAgent != "claude-cli/2.1.60 (external, cli)" { + t.Fatalf("cached.UserAgent = %q, want %q", cached.UserAgent, "claude-cli/2.1.60 (external, cli)") } - if cached.PackageVersion != "0.75.0" { - t.Fatalf("cached.PackageVersion = %q, want %q", cached.PackageVersion, "0.75.0") + if cached.PackageVersion != "0.70.0" { + t.Fatalf("cached.PackageVersion = %q, want %q", cached.PackageVersion, "0.70.0") } if cached.OS != "MacOS" || cached.Arch != "arm64" { t.Fatalf("cached platform = %s/%s, want %s/%s", cached.OS, cached.Arch, "MacOS", "arm64") @@ -553,7 +568,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64") + assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") } func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { @@ -585,7 +600,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "Linux", "x64") + assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64") thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{ "User-Agent": []string{"lobe-chat/1.0"}, @@ -605,7 +620,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) { "X-Stainless-Arch": []string{"x64"}, }) applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.61 (external, cli)", "0.73.0", "v24.2.0", "Windows", "x64") + assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64") } func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForClaudeClients(t *testing.T) { @@ -637,7 +652,7 @@ func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForCla }) applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, nil, cfg, nil, true) - assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.74.0", "v24.3.0", "Linux", "x64") + assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch()) } func TestApplyClaudeHeaders_LegacyThirdPartyUsesStableConfiguredOSArch(t *testing.T) { @@ -3904,11 +3919,14 @@ func TestClaudeExecutor_CustomBaseURLOAuthGeneratesMissingCCH(t *testing.T) { defer server.Close() executor := NewClaudeExecutor(&config.Config{}) - auth := &cliproxyauth.Auth{Attributes: map[string]string{ - "api_key": "sk-ant-oat-custom-cch", - "base_url": server.URL, - "cloak_mode": "never", - }} + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{ + "api_key": "sk-ant-oat-custom-cch", + "base_url": server.URL, + "cloak_mode": "never", + }, + Metadata: claudeOAuthTestMetadata(), + } payload := []byte(`{"model":"claude-opus-4-6","system":"keep original system","messages":[{"role":"user","content":"hello"}],"max_tokens":64}`) _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ @@ -3946,7 +3964,7 @@ func TestClaudeExecutor_RebuildMidSystemMessageDisabledByDefault(t *testing.T) { "api_key": "key-123", "base_url": server.URL, }} - payload := []byte(`{"system":[{"type":"text","text":"Top rule","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"system","content":"Mid rule"},{"role":"user","content":[{"type":"text","text":"continue"}]}],"metadata":{"user_id":"test-user-id"}}`) + payload := []byte(`{"system":[{"type":"text","text":"Top rule","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"system","content":"Mid rule"},{"role":"user","content":[{"type":"text","text":"continue"}]}],"metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"account_uuid\":\"\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"}}`) ctx := contextWithGinHeaders(map[string]string{ "User-Agent": "claude-cli/2.1.220 (external, cli)", "X-App": "cli", @@ -3992,7 +4010,7 @@ func TestClaudeExecutor_RebuildMidSystemMessageOptInMovesSystemMessages(t *testi "api_key": "key-123", "base_url": server.URL, }} - payload := []byte(`{"system":"Top rule","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"system","content":"Mid string rule"},{"role":"assistant","content":[{"type":"text","text":"ok"}]},{"role":"system","content":[{"type":"text","text":"Mid array rule","cache_control":{"type":"ephemeral"}}]},{"role":"user","content":[{"type":"text","text":"continue"}]}],"metadata":{"user_id":"test-user-id"}}`) + payload := []byte(`{"system":"Top rule","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]},{"role":"system","content":"Mid string rule"},{"role":"assistant","content":[{"type":"text","text":"ok"}]},{"role":"system","content":[{"type":"text","text":"Mid array rule","cache_control":{"type":"ephemeral"}}]},{"role":"user","content":[{"type":"text","text":"continue"}]}],"metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"account_uuid\":\"\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"}}`) ctx := contextWithGinHeaders(map[string]string{ "User-Agent": "claude-cli/2.1.220 (external, cli)", "X-App": "cli", @@ -4551,10 +4569,13 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing. defer server.Close() executor := NewClaudeExecutor(&config.Config{}) - auth := &cliproxyauth.Auth{Attributes: map[string]string{ - "api_key": "sk-ant-oat01-test", - "base_url": server.URL, - }} + auth := &cliproxyauth.Auth{ + Attributes: map[string]string{ + "api_key": "sk-ant-oat01-test", + "base_url": server.URL, + }, + Metadata: claudeOAuthTestMetadata(), + } payload := []byte(`{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"run echo hi"}],` + `"tools":[{"type":"function","function":{"name":"bash","description":"run shell",` + `"parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}}]}`) @@ -4602,6 +4623,7 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) { "api_key": "sk-ant-oat-mcp-round-trip", "base_url": server.URL, }, + Metadata: claudeOAuthTestMetadata(), } payload := []byte(`{"model":"claude-opus-5","system":"messages-system-prompt","messages":[{"role":"user","content":"search"}],"tools":[{"name":"search_web","description":"search","input_schema":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}]}`) resp, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index a0ca7d5f..123d4f85 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -136,11 +136,11 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy if len(opts.OriginalRequest) > 0 { originalPayload = opts.OriginalRequest } - incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, true) + incomingHeaders, claudeCodeDetection := detectIncomingClaudeCodeRequest(ctx, opts.Headers, originalPayload, true, e.cfg) confirmedClaudeCode := claudeCodeDetection.Confirmed claudeSessionID := "" if oauthToken { - claudeSessionID = helps.ClaudeAgentSessionUUID(incomingHeaders, originalPayload, req.Payload, opts.Metadata, req.Metadata) + claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } // Use streaming translation to preserve function calling, except for claude. stream := from != to diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go index dc53d5a7..0aa06027 100644 --- a/internal/runtime/executor/helps/claude_client_detection.go +++ b/internal/runtime/executor/helps/claude_client_detection.go @@ -5,12 +5,14 @@ import ( "regexp" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/tidwall/gjson" ) var ( claudeCodeUserAgentPattern = regexp.MustCompile(`(?i)^claude-cli/`) claudeCodeUserAgentDetailsPattern = regexp.MustCompile(`(?i)^claude-cli/\S+\s+\(external,\s*([^,)]+)(?:,\s*agent-sdk/([^,)]+))?`) + claudeCodeNativeUserAgentPattern = regexp.MustCompile(`(?i)^claude-cli/[0-9]+\.[0-9]+\.[0-9]+\s+\(external,\s*[^,)]+(?:,\s*agent-sdk/[0-9]+\.[0-9]+\.[0-9]+)?\)$`) ) var claudeCodeSubclientByEntrypoint = map[string]string{ @@ -72,26 +74,40 @@ type ClaudeCodeRequestDetection struct { // Only Anthropic first-party product entrypoints are confirmed for pass-through. // Generic sdk-ts/sdk-py Agent SDK entrypoints remain unconfirmed and receive // CLI cloaking; native Claude Code print mode keeps its original sdk-cli identity. -func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bool) ClaudeCodeRequestDetection { +func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bool, configs ...*config.Config) ClaudeCodeRequestDetection { + var cfg *config.Config + if len(configs) > 0 { + cfg = configs[0] + } userAgent := headerValue(headers, "User-Agent") entrypoint, agentSDKVersion := parseClaudeCodeUserAgentDetails(userAgent) detection := ClaudeCodeRequestDetection{ XAppCLI: headerValue(headers, "X-App") == "cli", - UserAgent: claudeCodeUserAgentPattern.MatchString(userAgent), - BetasPresent: headerPresent(headers, "Anthropic-Beta"), + UserAgent: plausibleClaudeCodeUserAgent(userAgent, cfg), + BetasPresent: headerContainsClaudeCodeBeta(headers), Entrypoint: entrypoint, Subclient: claudeCodeSubclientByEntrypoint[entrypoint], AgentSDKVersion: agentSDKVersion, } metadataUserID := gjson.GetBytes(payload, "metadata.user_id") - detection.MetadataUserID = metadataUserID.Exists() && metadataUserID.Type == gjson.String + detection.MetadataUserID = metadataUserID.Exists() && metadataUserID.Type == gjson.String && isValidUserID(metadataUserID.String()) detection.StrongSignals = detection.XAppCLI && detection.UserAgent && detection.BetasPresent && (countTokens || detection.MetadataUserID) detection.NativeClient = nativeClaudeEntrypoints[entrypoint] detection.Confirmed = detection.StrongSignals && detection.NativeClient return detection } +func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool { + userAgent = strings.TrimSpace(userAgent) + if !claudeCodeUserAgentPattern.MatchString(userAgent) || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) { + return false + } + candidate, okCandidate := parseClaudeCLIVersion(userAgent) + baseline, okBaseline := parseClaudeCLIVersion(defaultClaudeDeviceProfile(cfg).UserAgent) + return okCandidate && okBaseline && plausibleClaudeCLIVersion(candidate, baseline) +} + func parseClaudeCodeUserAgentDetails(userAgent string) (entrypoint, agentSDKVersion string) { matches := claudeCodeUserAgentDetailsPattern.FindStringSubmatch(strings.TrimSpace(userAgent)) if len(matches) < 2 { @@ -120,13 +136,20 @@ func headerValue(headers http.Header, name string) string { return "" } -func headerPresent(headers http.Header, name string) bool { +func headerContainsClaudeCodeBeta(headers http.Header) bool { if headers == nil { return false } - for key := range headers { - if strings.EqualFold(key, name) { - return true + for key, values := range headers { + if !strings.EqualFold(key, "Anthropic-Beta") { + continue + } + for _, value := range values { + for _, beta := range strings.Split(value, ",") { + if strings.TrimSpace(beta) == "claude-code-20250219" { + return true + } + } } } return false diff --git a/internal/runtime/executor/helps/claude_client_detection_test.go b/internal/runtime/executor/helps/claude_client_detection_test.go index db51d8e2..913b26bc 100644 --- a/internal/runtime/executor/helps/claude_client_detection_test.go +++ b/internal/runtime/executor/helps/claude_client_detection_test.go @@ -4,8 +4,17 @@ import ( "encoding/json" "net/http" "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) +const validClaudeCodeMetadataUserID = `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","account_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","session_id":"11111111-2222-4333-8444-555555555555"}` + +func claudeCodeDetectionPayload(userID string) []byte { + encodedUserID, _ := json.Marshal(userID) + return []byte(`{"metadata":{"user_id":` + string(encodedUserID) + `}}`) +} + func confirmedClaudeCodeHeaders() http.Header { return http.Header{ "User-Agent": {"claude-cli/2.1.220 (external, cli)"}, @@ -15,7 +24,7 @@ func confirmedClaudeCodeHeaders() http.Header { } func TestDetectClaudeCodeRequestRequiresAllFourMessageSignals(t *testing.T) { - payload := []byte(`{"metadata":{"user_id":"{\"device_id\":\"abc\",\"session_id\":\"session\"}"}}`) + payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID) detection := DetectClaudeCodeRequest(confirmedClaudeCodeHeaders(), payload, false) if !detection.Confirmed || !detection.StrongSignals || !detection.NativeClient { @@ -26,8 +35,26 @@ func TestDetectClaudeCodeRequestRequiresAllFourMessageSignals(t *testing.T) { } } +func TestDetectClaudeCodeRequestAcceptsConfiguredMeasuredBaseline(t *testing.T) { + headers := confirmedClaudeCodeHeaders() + headers.Set("User-Agent", "claude-cli/2.2.0 (external, cli)") + payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID) + if detection := DetectClaudeCodeRequest(headers, payload, false); detection.Confirmed { + t.Fatalf("default detection = %#v, want unconfigured 2.2.0 rejected", detection) + } + + cfg := &config.Config{ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "claude-cli/2.2.0 (external, cli)", + PackageVersion: "0.95.0", + RuntimeVersion: "v26.4.0", + }} + if detection := DetectClaudeCodeRequest(headers, payload, false, cfg); !detection.Confirmed { + t.Fatalf("configured detection = %#v, want measured baseline confirmed", detection) + } +} + func TestDetectClaudeCodeRequestRejectsEachMissingMessageSignal(t *testing.T) { - payload := []byte(`{"metadata":{"user_id":"user-id"}}`) + payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID) for _, test := range []struct { name string headers http.Header @@ -47,7 +74,7 @@ func TestDetectClaudeCodeRequestRejectsEachMissingMessageSignal(t *testing.T) { } func TestDetectClaudeCodeRequestClassifiesEntrypoints(t *testing.T) { - payload := []byte(`{"metadata":{"user_id":"user-id"}}`) + payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID) for _, test := range []struct { name string userAgent string @@ -99,18 +126,26 @@ func TestDetectClaudeCodeCountTokensAllowsMissingMetadata(t *testing.T) { } } -func TestDetectClaudeCodeRequestAcceptsJSONAndLegacyMetadataStrings(t *testing.T) { - for _, userID := range []string{ - `{"device_id":"abc","account_uuid":"","session_id":"session"}`, - "user_abc_account__session_session", - } { - encodedUserID, errMarshal := json.Marshal(userID) - if errMarshal != nil { - t.Fatalf("marshal user_id: %v", errMarshal) - } - payload := []byte(`{"metadata":{"user_id":` + string(encodedUserID) + `}}`) - if detection := DetectClaudeCodeRequest(confirmedClaudeCodeHeaders(), payload, false); !detection.Confirmed { - t.Fatalf("user_id %q detection = %#v, want confirmed", userID, detection) - } +func TestDetectClaudeCodeRequestRejectsMalformedNativeSignals(t *testing.T) { + tests := []struct { + name string + headers http.Header + userID string + }{ + {name: "legacy metadata", headers: confirmedClaudeCodeHeaders(), userID: "user_abc_account__session_session"}, + {name: "short device", headers: confirmedClaudeCodeHeaders(), userID: `{"device_id":"abc","account_uuid":"","session_id":"11111111-2222-4333-8444-555555555555"}`}, + {name: "uppercase device", headers: confirmedClaudeCodeHeaders(), userID: `{"device_id":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","account_uuid":"","session_id":"11111111-2222-4333-8444-555555555555"}`}, + {name: "invalid session", headers: confirmedClaudeCodeHeaders(), userID: `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","account_uuid":"","session_id":"session"}`}, + {name: "malformed user agent", headers: http.Header{"User-Agent": {"claude-cli/not-a-version (external, cli)"}, "X-App": {"cli"}, "Anthropic-Beta": {"claude-code-20250219"}}, userID: validClaudeCodeMetadataUserID}, + {name: "unmeasured next-minor user agent", headers: http.Header{"User-Agent": {"claude-cli/2.2.0 (external, cli)"}, "X-App": {"cli"}, "Anthropic-Beta": {"claude-code-20250219"}}, userID: validClaudeCodeMetadataUserID}, + {name: "implausible future user agent", headers: http.Header{"User-Agent": {"claude-cli/999.0.0 (external, cli)"}, "X-App": {"cli"}, "Anthropic-Beta": {"claude-code-20250219"}}, userID: validClaudeCodeMetadataUserID}, + {name: "unrelated beta", headers: http.Header{"User-Agent": {"claude-cli/2.1.220 (external, cli)"}, "X-App": {"cli"}, "Anthropic-Beta": {"anything"}}, userID: validClaudeCodeMetadataUserID}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if detection := DetectClaudeCodeRequest(test.headers, claudeCodeDetectionPayload(test.userID), false); detection.Confirmed { + t.Fatalf("detection = %#v, want malformed signal to use local profile", detection) + } + }) } } diff --git a/internal/runtime/executor/helps/claude_credential_identity.go b/internal/runtime/executor/helps/claude_credential_identity.go index e93b0756..c55bc72c 100644 --- a/internal/runtime/executor/helps/claude_credential_identity.go +++ b/internal/runtime/executor/helps/claude_credential_identity.go @@ -1,6 +1,7 @@ package helps import ( + "bytes" "context" "encoding/json" "fmt" @@ -11,12 +12,33 @@ import ( claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" - "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) -// ClaudeAgentSessionUUID maps the downstream agent conversation to one stable UUID. +// ClaudeAgentSessionUUID maps the downstream agent conversation to one stable UUID, +// preserving native Claude Code session signals. func ClaudeAgentSessionUUID(headers http.Header, originalPayload, translatedPayload []byte, metadataSets ...map[string]any) string { + return claudeAgentSessionUUID(headers, originalPayload, translatedPayload, metadataSets...) +} + +// ClaudeAgentSessionUUIDForRequest preserves Claude-specific session signals only +// for a confirmed native caller. Other callers use protocol session fields, +// execution metadata, or the stable derived conversation root. +func ClaudeAgentSessionUUIDForRequest(headers http.Header, originalPayload, translatedPayload []byte, confirmedClaudeCode bool, metadataSets ...map[string]any) string { + if !confirmedClaudeCode { + headers = headers.Clone() + for key := range headers { + if strings.EqualFold(key, "X-Claude-Code-Session-Id") { + delete(headers, key) + } + } + originalPayload = withoutClaudeMetadataUserID(originalPayload) + translatedPayload = withoutClaudeMetadataUserID(translatedPayload) + } + return claudeAgentSessionUUID(headers, originalPayload, translatedPayload, metadataSets...) +} + +func claudeAgentSessionUUID(headers http.Header, originalPayload, translatedPayload []byte, metadataSets ...map[string]any) string { metadata := mergeClaudeSessionMetadata(metadataSets...) identity := cliproxyauth.ExtractSessionID(headers, originalPayload, metadata) if identity == "" && len(translatedPayload) > 0 { @@ -37,6 +59,17 @@ func ClaudeAgentSessionUUID(headers http.Header, originalPayload, translatedPayl return uuid.NewSHA1(uuid.NameSpaceOID, []byte(stableInput)).String() } +func withoutClaudeMetadataUserID(payload []byte) []byte { + if len(payload) == 0 { + return payload + } + updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id") + if errDelete != nil { + return payload + } + return updated +} + func mergeClaudeSessionMetadata(metadataSets ...map[string]any) map[string]any { var merged map[string]any for _, metadata := range metadataSets { @@ -171,6 +204,24 @@ func ApplyClaudeCredentialMetadata(payload []byte, auth *cliproxyauth.Auth, sess if auth == nil { return nil, "", fmt.Errorf("apply Claude credential metadata: auth is nil") } + metadata, metadataPresent, errMetadata := uniqueClaudeJSONObjectMember(payload, "metadata") + if errMetadata != nil { + return nil, "", fmt.Errorf("apply Claude credential metadata: %w", errMetadata) + } + var existing string + if metadataPresent { + trimmedMetadata := bytes.TrimSpace(metadata) + if len(trimmedMetadata) >= 2 && trimmedMetadata[0] == '{' { + userID, userIDPresent, errUserID := uniqueClaudeJSONObjectMember(trimmedMetadata, "user_id") + if errUserID != nil { + return nil, "", fmt.Errorf("apply Claude credential metadata: metadata: %w", errUserID) + } + if userIDPresent && json.Unmarshal(userID, &existing) != nil { + existing = "" + } + } + } + deviceIDs, _, errDeviceIDs := claudeauth.EnsureDeviceIDPoolFor(&auth.Metadata) if errDeviceIDs != nil { return nil, "", errDeviceIDs @@ -179,21 +230,14 @@ func ApplyClaudeCredentialMetadata(payload []byte, auth *cliproxyauth.Auth, sess if errDeviceID != nil { return nil, "", errDeviceID } - - existing := strings.TrimSpace(gjson.GetBytes(payload, "metadata.user_id").String()) - encoded := []byte(existing) - if !gjson.ValidBytes(encoded) || !gjson.ParseBytes(encoded).IsObject() { - encoded = []byte(`{}`) - } - var errSetIdentity error - if encoded, errSetIdentity = sjson.SetBytes(encoded, "device_id", deviceID); errSetIdentity != nil { - return nil, "", fmt.Errorf("set Claude credential device ID: %w", errSetIdentity) - } - if encoded, errSetIdentity = sjson.SetBytes(encoded, "account_uuid", ClaudeCredentialAccountUUID(auth)); errSetIdentity != nil { - return nil, "", fmt.Errorf("set Claude credential account UUID: %w", errSetIdentity) + accountUUID := ClaudeCredentialAccountUUID(auth) + if accountUUID == "" { + return nil, "", fmt.Errorf("apply Claude credential metadata: account UUID is empty") } - if encoded, errSetIdentity = sjson.SetBytes(encoded, "session_id", sessionID); errSetIdentity != nil { - return nil, "", fmt.Errorf("set Claude credential session ID: %w", errSetIdentity) + + encoded, errIdentity := rebuildClaudeMetadataUserID(existing, deviceID, accountUUID, sessionID) + if errIdentity != nil { + return nil, "", fmt.Errorf("apply Claude credential metadata: %w", errIdentity) } updated, errSet := sjson.SetBytes(payload, "metadata.user_id", string(encoded)) if errSet != nil { @@ -201,3 +245,177 @@ func ApplyClaudeCredentialMetadata(payload []byte, auth *cliproxyauth.Auth, sess } return updated, deviceID, nil } + +type claudeJSONMember struct { + key string + value json.RawMessage +} + +func uniqueClaudeJSONObjectMember(raw []byte, target string) ([]byte, bool, error) { + raw = bytes.TrimSpace(raw) + if !json.Valid(raw) || len(raw) < 2 || raw[0] != '{' { + return nil, false, fmt.Errorf("request must be a JSON object") + } + + position := 1 + found := false + var value []byte + for { + position = skipClaudeJSONWhitespace(raw, position) + if position >= len(raw) { + return nil, false, fmt.Errorf("unterminated JSON object") + } + if raw[position] == '}' { + break + } + keyStart := position + keyEnd := skipClaudeJSONString(raw, keyStart) + var key string + if errUnmarshal := json.Unmarshal(raw[keyStart:keyEnd], &key); errUnmarshal != nil { + return nil, false, fmt.Errorf("decode JSON object key: %w", errUnmarshal) + } + position = skipClaudeJSONWhitespace(raw, keyEnd) + if position >= len(raw) || raw[position] != ':' { + return nil, false, fmt.Errorf("JSON object key %q is missing a value", key) + } + position = skipClaudeJSONWhitespace(raw, position+1) + valueStart := position + position = skipClaudeJSONValue(raw, position) + if key == target { + if found { + return nil, false, fmt.Errorf("duplicate JSON object key %q", target) + } + found = true + value = raw[valueStart:position] + } + position = skipClaudeJSONWhitespace(raw, position) + if position < len(raw) && raw[position] == ',' { + position++ + continue + } + if position >= len(raw) || raw[position] != '}' { + return nil, false, fmt.Errorf("JSON object key %q has an invalid terminator", key) + } + } + return value, found, nil +} + +func skipClaudeJSONWhitespace(raw []byte, position int) int { + for position < len(raw) { + switch raw[position] { + case ' ', '\t', '\r', '\n': + position++ + default: + return position + } + } + return position +} + +func skipClaudeJSONString(raw []byte, position int) int { + if position >= len(raw) || raw[position] != '"' { + return position + } + position++ + for position < len(raw) { + switch raw[position] { + case '\\': + position += 2 + case '"': + return position + 1 + default: + position++ + } + } + return position +} + +func skipClaudeJSONValue(raw []byte, position int) int { + if position >= len(raw) { + return position + } + switch raw[position] { + case '"': + return skipClaudeJSONString(raw, position) + case '{', '[': + stack := []byte{raw[position]} + position++ + for position < len(raw) && len(stack) > 0 { + switch raw[position] { + case '"': + position = skipClaudeJSONString(raw, position) + continue + case '{', '[': + stack = append(stack, raw[position]) + case '}', ']': + stack = stack[:len(stack)-1] + } + position++ + } + return position + default: + for position < len(raw) { + switch raw[position] { + case ',', '}', ']', ' ', '\t', '\r', '\n': + return position + default: + position++ + } + } + return position + } +} + +func rebuildClaudeMetadataUserID(existing, deviceID, accountUUID, sessionID string) ([]byte, error) { + extras := make([]claudeJSONMember, 0) + rawExisting := []byte(strings.TrimSpace(existing)) + if json.Valid(rawExisting) && len(rawExisting) >= 2 && rawExisting[0] == '{' { + decoder := json.NewDecoder(bytes.NewReader(rawExisting)) + _, _ = decoder.Token() + seen := make(map[string]bool) + for decoder.More() { + token, errToken := decoder.Token() + if errToken != nil { + return nil, errToken + } + key, ok := token.(string) + if !ok { + return nil, fmt.Errorf("metadata.user_id contains a non-string key") + } + if seen[key] { + return nil, fmt.Errorf("metadata.user_id contains duplicate key %q", key) + } + seen[key] = true + var value json.RawMessage + if errDecode := decoder.Decode(&value); errDecode != nil { + return nil, errDecode + } + switch key { + case "device_id", "account_uuid", "session_id": + default: + extras = append(extras, claudeJSONMember{key: key, value: value}) + } + } + } + + var output bytes.Buffer + output.WriteString(`{"device_id":`) + writeClaudeJSONQuoted(&output, deviceID) + output.WriteString(`,"account_uuid":`) + writeClaudeJSONQuoted(&output, accountUUID) + output.WriteString(`,"session_id":`) + writeClaudeJSONQuoted(&output, sessionID) + for _, extra := range extras { + output.WriteByte(',') + writeClaudeJSONQuoted(&output, extra.key) + output.WriteByte(':') + output.Write(extra.value) + } + output.WriteByte('}') + return output.Bytes(), nil +} + +func writeClaudeJSONQuoted(output *bytes.Buffer, value string) { + encoded, _ := json.Marshal(value) + output.Write(encoded) +} diff --git a/internal/runtime/executor/helps/claude_credential_identity_race_test.go b/internal/runtime/executor/helps/claude_credential_identity_race_test.go index 695d8c44..bb884085 100644 --- a/internal/runtime/executor/helps/claude_credential_identity_race_test.go +++ b/internal/runtime/executor/helps/claude_credential_identity_race_test.go @@ -14,7 +14,10 @@ import ( // outside claudeDevicePoolMu, which aborts the process with "concurrent map // writes" rather than failing a request. Run with -race. func TestApplyClaudeCredentialMetadataConcurrentSharedAuth(t *testing.T) { - auth := &cliproxyauth.Auth{ID: "shared-credential"} + auth := &cliproxyauth.Auth{ + ID: "shared-credential", + Metadata: map[string]any{"account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"}, + } payload := []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`) const goroutines = 32 diff --git a/internal/runtime/executor/helps/claude_credential_identity_test.go b/internal/runtime/executor/helps/claude_credential_identity_test.go index 22001ebb..eb65f9c3 100644 --- a/internal/runtime/executor/helps/claude_credential_identity_test.go +++ b/internal/runtime/executor/helps/claude_credential_identity_test.go @@ -37,9 +37,27 @@ func (fake *fakeClaudeCredentialDevicePoolKV) KVSet(_ context.Context, key strin func TestClaudeAgentSessionUUIDPreservesNativeSession(t *testing.T) { const sessionID = "11111111-2222-4333-8444-555555555555" - got := ClaudeAgentSessionUUID(http.Header{"X-Claude-Code-Session-Id": {sessionID}}, nil, nil) + got := ClaudeAgentSessionUUIDForRequest(http.Header{"X-Claude-Code-Session-Id": {sessionID}}, nil, nil, true) if got != sessionID { - t.Fatalf("ClaudeAgentSessionUUID() = %q, want native session %q", got, sessionID) + t.Fatalf("ClaudeAgentSessionUUIDForRequest() = %q, want native session %q", got, sessionID) + } +} + +func TestClaudeAgentSessionUUIDIgnoresUnconfirmedClaudeSignals(t *testing.T) { + const nativeSessionID = "11111111-2222-4333-8444-555555555555" + metadata := map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "non-native-conversation"} + got := ClaudeAgentSessionUUIDForRequest( + http.Header{"X-Claude-Code-Session-Id": {nativeSessionID}}, + []byte(`{"metadata":{"user_id":"{\"device_id\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"session_id\":\"11111111-2222-4333-8444-555555555555\"}"}}`), + nil, + false, + metadata, + ) + if got == nativeSessionID { + t.Fatalf("ClaudeAgentSessionUUIDForRequest() = native session %q for unconfirmed caller", got) + } + if repeated := ClaudeAgentSessionUUIDForRequest(nil, nil, nil, false, metadata); repeated != got { + t.Fatalf("derived session changed: first=%q repeated=%q", got, repeated) } } @@ -145,6 +163,55 @@ func TestApplyClaudeCredentialMetadataUsesCredentialDeviceAndPreservesExtras(t * } wantPrefix := `{"device_id":"` + selectedDevice + `","account_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","session_id":"` + sessionID + `"` if !strings.HasPrefix(userID, wantPrefix) { - t.Fatalf("metadata.user_id = %q, want original native identity field order preserved", userID) + t.Fatalf("metadata.user_id = %q, want credential identity fields first", userID) + } +} + +func TestApplyClaudeCredentialMetadataRejectsDuplicateIdentityContainers(t *testing.T) { + auth := &cliproxyauth.Auth{Metadata: map[string]any{ + "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + claudeauth.ClaudeDeviceIDsMetadataKey: []string{ + "0000000000000000000000000000000000000000000000000000000000000000", + }, + }} + const sessionID = "11111111-2222-4333-8444-555555555555" + tests := []struct { + name string + body string + }{ + { + name: "duplicate top-level metadata", + body: `{"messages":[],"metadata":{"user_id":"{}"},"metadata":{"user_id":"{}"}}`, + }, + { + name: "duplicate metadata user ID", + body: `{"messages":[],"metadata":{"user_id":"{}","user_id":"{}"}}`, + }, + { + name: "duplicate encoded account UUID", + body: `{"messages":[],"metadata":{"user_id":"{\"account_uuid\":\"first\",\"account_uuid\":\"last\"}"}}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, _, errApply := ApplyClaudeCredentialMetadata([]byte(test.body), auth, sessionID); errApply == nil { + t.Fatal("ApplyClaudeCredentialMetadata() error = nil, want duplicate-key rejection") + } + }) + } +} + +func TestApplyClaudeCredentialMetadataRequiresAccountUUID(t *testing.T) { + auth := &cliproxyauth.Auth{Metadata: map[string]any{ + claudeauth.ClaudeDeviceIDsMetadataKey: []string{ + "0000000000000000000000000000000000000000000000000000000000000000", + }, + }} + if _, _, errApply := ApplyClaudeCredentialMetadata( + []byte(`{"messages":[]}`), + auth, + "11111111-2222-4333-8444-555555555555", + ); errApply == nil { + t.Fatal("ApplyClaudeCredentialMetadata() error = nil, want missing account UUID rejection") } } diff --git a/internal/runtime/executor/helps/claude_device_profile.go b/internal/runtime/executor/helps/claude_device_profile.go index 641e73cf..95f5b679 100644 --- a/internal/runtime/executor/helps/claude_device_profile.go +++ b/internal/runtime/executor/helps/claude_device_profile.go @@ -31,7 +31,9 @@ const ( ) var ( - claudeCLIVersionPattern = regexp.MustCompile(`^claude-cli/(\d+)\.(\d+)\.(\d+)`) + claudeCLIVersionPattern = regexp.MustCompile(`^claude-cli/(\d+)\.(\d+)\.(\d+)`) + claudePackageVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + claudeRuntimeVersionPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) claudeDeviceProfileCache = make(map[string]claudeDeviceProfileCacheEntry) claudeDeviceProfileCacheMu sync.RWMutex @@ -210,14 +212,20 @@ func shouldUpgradeClaudeDeviceProfile(candidate, current ClaudeDeviceProfile) bo return candidate.version.Compare(current.version) > 0 } +func plausibleClaudeCLIVersion(candidate, baseline claudeCLIVersion) bool { + return candidate.Compare(baseline) == 0 +} + func meetsClaudeDeviceProfileBaseline(candidate, baseline ClaudeDeviceProfile) bool { if candidate.UserAgent == "" || !candidate.hasVersion { return false } if baseline.UserAgent == "" || !baseline.hasVersion { - return true + return false } - return candidate.version.Compare(baseline.version) >= 0 + return plausibleClaudeCLIVersion(candidate.version, baseline.version) && + candidate.PackageVersion == baseline.PackageVersion && + candidate.RuntimeVersion == baseline.RuntimeVersion } func pinClaudeDeviceProfilePlatform(profile, baseline ClaudeDeviceProfile) ClaudeDeviceProfile { @@ -230,7 +238,7 @@ func pinClaudeDeviceProfilePlatform(profile, baseline ClaudeDeviceProfile) Claud // baseline platform and enforces the baseline software fingerprint as a floor. func normalizeClaudeDeviceProfile(profile, baseline ClaudeDeviceProfile) ClaudeDeviceProfile { profile = pinClaudeDeviceProfilePlatform(profile, baseline) - if profile.UserAgent == "" || !profile.hasVersion || shouldUpgradeClaudeDeviceProfile(baseline, profile) { + if !meetsClaudeDeviceProfileBaseline(profile, baseline) { profile.UserAgent = baseline.UserAgent profile.PackageVersion = baseline.PackageVersion profile.RuntimeVersion = baseline.RuntimeVersion @@ -247,15 +255,23 @@ func extractClaudeDeviceProfile(headers http.Header, cfg *config.Config) (Claude userAgent := strings.TrimSpace(headers.Get("User-Agent")) version, ok := parseClaudeCLIVersion(userAgent) - if !ok { + if !ok || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) { return ClaudeDeviceProfile{}, false } baseline := defaultClaudeDeviceProfile(cfg) + packageVersion := firstNonEmptyHeader(headers, "X-Stainless-Package-Version", baseline.PackageVersion) + if !claudePackageVersionPattern.MatchString(packageVersion) { + packageVersion = baseline.PackageVersion + } + runtimeVersion := firstNonEmptyHeader(headers, "X-Stainless-Runtime-Version", baseline.RuntimeVersion) + if !claudeRuntimeVersionPattern.MatchString(runtimeVersion) { + runtimeVersion = baseline.RuntimeVersion + } profile := ClaudeDeviceProfile{ UserAgent: userAgent, - PackageVersion: firstNonEmptyHeader(headers, "X-Stainless-Package-Version", baseline.PackageVersion), - RuntimeVersion: firstNonEmptyHeader(headers, "X-Stainless-Runtime-Version", baseline.RuntimeVersion), + PackageVersion: packageVersion, + RuntimeVersion: runtimeVersion, OS: firstNonEmptyHeader(headers, "X-Stainless-Os", baseline.OS), Arch: firstNonEmptyHeader(headers, "X-Stainless-Arch", baseline.Arch), version: version, @@ -586,23 +602,23 @@ func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg return } profile := defaultClaudeDeviceProfile(cfg) - miscEnsure := func(name, fallback string) { - if strings.TrimSpace(r.Header.Get(name)) != "" { + miscEnsure := func(name, fallback string, valid func(string) bool) { + if current := strings.TrimSpace(r.Header.Get(name)); current != "" && (valid == nil || valid(current)) { return } - if strings.TrimSpace(ginHeaders.Get(name)) != "" { - r.Header.Set(name, strings.TrimSpace(ginHeaders.Get(name))) + if incoming := strings.TrimSpace(ginHeaders.Get(name)); incoming != "" && (valid == nil || valid(incoming)) { + r.Header.Set(name, incoming) return } r.Header.Set(name, fallback) } if confirmedClaudeCode { - miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion) - miscEnsure("X-Stainless-Package-Version", profile.PackageVersion) - miscEnsure("X-Stainless-Os", mapStainlessOS()) - miscEnsure("X-Stainless-Arch", mapStainlessArch()) - if clientUA := strings.TrimSpace(ginHeaders.Get("User-Agent")); clientUA != "" { + miscEnsure("X-Stainless-Runtime-Version", profile.RuntimeVersion, func(value string) bool { return value == profile.RuntimeVersion }) + miscEnsure("X-Stainless-Package-Version", profile.PackageVersion, func(value string) bool { return value == profile.PackageVersion }) + miscEnsure("X-Stainless-Os", mapStainlessOS(), nil) + miscEnsure("X-Stainless-Arch", mapStainlessArch(), nil) + if clientUA := strings.TrimSpace(ginHeaders.Get("User-Agent")); plausibleClaudeCodeUserAgent(clientUA, cfg) { r.Header.Set("User-Agent", clientUA) return } diff --git a/internal/runtime/executor/helps/claude_device_profile_test.go b/internal/runtime/executor/helps/claude_device_profile_test.go index ba2401de..76ee3c8c 100644 --- a/internal/runtime/executor/helps/claude_device_profile_test.go +++ b/internal/runtime/executor/helps/claude_device_profile_test.go @@ -106,13 +106,79 @@ func mustClaudeDeviceProfileJSON(t *testing.T, value claudeDeviceProfileKVValue) func claudeDeviceHeaders(userAgent string) http.Header { return http.Header{ "User-Agent": {userAgent}, - "X-Stainless-Package-Version": {"0.80.0"}, - "X-Stainless-Runtime-Version": {"v24.4.0"}, + "X-Stainless-Package-Version": {defaultClaudeFingerprintPackageVersion}, + "X-Stainless-Runtime-Version": {defaultClaudeFingerprintRuntimeVersion}, "X-Stainless-Os": {"Windows"}, "X-Stainless-Arch": {"x64"}, } } +func TestResolveClaudeDeviceProfileLocalUsesBaselineForInvalidSignals(t *testing.T) { + ResetClaudeDeviceProfileCache() + auth := &cliproxyauth.Auth{ID: "auth-invalid-signals"} + headers := claudeDeviceHeaders("claude-cli/999.0.0 (external, cli)") + headers.Set("X-Stainless-Package-Version", "999.0.0") + headers.Set("X-Stainless-Runtime-Version", "v999.0.0") + + profile := resolveClaudeDeviceProfileLocal(auth, "api-key", headers, nil) + baseline := defaultClaudeDeviceProfile(nil) + if profile.UserAgent != baseline.UserAgent || profile.PackageVersion != baseline.PackageVersion || profile.RuntimeVersion != baseline.RuntimeVersion { + t.Fatalf("invalid profile = %#v, want local baseline %#v", profile, baseline) + } +} + +func TestApplyClaudeLegacyDeviceHeadersReplacesInvalidNativeSoftwareSignals(t *testing.T) { + request, errRequest := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + if errRequest != nil { + t.Fatal(errRequest) + } + incoming := claudeDeviceHeaders("claude-cli/999.0.0 (external, cli)") + incoming.Set("X-Stainless-Package-Version", "999.0.0") + incoming.Set("X-Stainless-Runtime-Version", "v999.0.0") + + ApplyClaudeLegacyDeviceHeaders(request, incoming, nil, true) + + baseline := defaultClaudeDeviceProfile(nil) + if got := request.Header.Get("User-Agent"); got != baseline.UserAgent { + t.Fatalf("User-Agent = %q, want local baseline %q", got, baseline.UserAgent) + } + if got := request.Header.Get("X-Stainless-Package-Version"); got != baseline.PackageVersion { + t.Fatalf("X-Stainless-Package-Version = %q, want %q", got, baseline.PackageVersion) + } + if got := request.Header.Get("X-Stainless-Runtime-Version"); got != baseline.RuntimeVersion { + t.Fatalf("X-Stainless-Runtime-Version = %q, want %q", got, baseline.RuntimeVersion) + } +} + +func TestApplyClaudeLegacyDeviceHeadersAcceptsConfiguredMeasuredBaseline(t *testing.T) { + request, errRequest := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", nil) + if errRequest != nil { + t.Fatal(errRequest) + } + cfg := &config.Config{ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{ + UserAgent: "claude-cli/2.2.0 (external, cli)", + PackageVersion: "0.95.0", + RuntimeVersion: "v26.4.0", + OS: "MacOS", + Arch: "arm64", + }} + incoming := claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)") + incoming.Set("X-Stainless-Package-Version", "0.95.0") + incoming.Set("X-Stainless-Runtime-Version", "v26.4.0") + + ApplyClaudeLegacyDeviceHeaders(request, incoming, cfg, true) + + if got := request.Header.Get("User-Agent"); got != "claude-cli/2.2.0 (external, cli)" { + t.Fatalf("User-Agent = %q, want configured measured baseline", got) + } + if got := request.Header.Get("X-Stainless-Package-Version"); got != "0.95.0" { + t.Fatalf("X-Stainless-Package-Version = %q, want 0.95.0", got) + } + if got := request.Header.Get("X-Stainless-Runtime-Version"); got != "v26.4.0" { + t.Fatalf("X-Stainless-Runtime-Version = %q, want v26.4.0", got) + } +} + func TestResolveClaudeDeviceProfileRequiredHomeReadWithoutCandidate(t *testing.T) { client := newFakeClaudeDeviceProfileKVClient() auth := &cliproxyauth.Auth{ID: "auth-1"} @@ -130,8 +196,8 @@ func TestResolveClaudeDeviceProfileRequiredHomeReadWithoutCandidate(t *testing.T if errProfile != nil { t.Fatalf("ResolveClaudeDeviceProfileRequired() error = %v", errProfile) } - if profile.UserAgent != "claude-cli/2.2.0 (external, cli)" { - t.Fatalf("UserAgent = %q, want cached profile", profile.UserAgent) + if profile.UserAgent != defaultClaudeFingerprintUserAgent { + t.Fatalf("UserAgent = %q, want local baseline %q for unmeasured cached profile", profile.UserAgent, defaultClaudeFingerprintUserAgent) } if profile.OS != defaultClaudeFingerprintOS || profile.Arch != defaultClaudeFingerprintArch { t.Fatalf("platform = %s/%s, want baseline pinned %s/%s", profile.OS, profile.Arch, defaultClaudeFingerprintOS, defaultClaudeFingerprintArch) @@ -146,12 +212,12 @@ func TestResolveClaudeDeviceProfileRequiredHomeCandidateLocksRereadsAndWrites(t auth := &cliproxyauth.Auth{ID: "auth-1"} useFakeClaudeDeviceProfileKVClient(t, client, true, nil) - profile, errProfile := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), nil) + profile, errProfile := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), nil) if errProfile != nil { t.Fatalf("ResolveClaudeDeviceProfileRequired() error = %v", errProfile) } - if profile.UserAgent != "claude-cli/2.2.0 (external, cli)" { - t.Fatalf("UserAgent = %q, want candidate", profile.UserAgent) + if profile.UserAgent != defaultClaudeFingerprintUserAgent { + t.Fatalf("UserAgent = %q, want candidate %q", profile.UserAgent, defaultClaudeFingerprintUserAgent) } if client.setNXCount != 1 || client.lastSetNXTTL != claudeDeviceProfileLockTTL { t.Fatalf("KVSetNX count/ttl = %d/%v, want 1/%v", client.setNXCount, client.lastSetNXTTL, claudeDeviceProfileLockTTL) @@ -169,17 +235,17 @@ func TestResolveClaudeDeviceProfileRequiredHomeSeparatesVSCodeAgentSDKFromCLI(t auth := &cliproxyauth.Auth{ID: "auth-home-subclient-isolation"} useFakeClaudeDeviceProfileKVClient(t, client, true, nil) - cliProfile, errCLI := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), nil) + cliProfile, errCLI := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), nil) if errCLI != nil { t.Fatalf("ResolveClaudeDeviceProfileRequired() CLI error = %v", errCLI) } - vscodeUA := "claude-cli/2.2.0 (external, claude-vscode, agent-sdk/0.3.220)" + vscodeUA := "claude-cli/2.1.220 (external, claude-vscode, agent-sdk/0.3.220)" vscodeProfile, errVSCode := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders(vscodeUA), nil) if errVSCode != nil { t.Fatalf("ResolveClaudeDeviceProfileRequired() VSCode error = %v", errVSCode) } - if cliProfile.UserAgent != "claude-cli/2.2.0 (external, cli)" { + if cliProfile.UserAgent != defaultClaudeFingerprintUserAgent { t.Fatalf("CLI UserAgent = %q, want CLI profile", cliProfile.UserAgent) } if vscodeProfile.UserAgent != vscodeUA { @@ -201,7 +267,7 @@ func TestResolveClaudeDeviceProfileRequiredHomeSeparatesVSCodeAgentSDKFromCLI(t } } -func TestResolveClaudeDeviceProfileRequiredHomeCandidateDoesNotDowngradeCachedProfile(t *testing.T) { +func TestResolveClaudeDeviceProfileRequiredHomeNormalizesUnmeasuredCachedProfile(t *testing.T) { client := newFakeClaudeDeviceProfileKVClient() auth := &cliproxyauth.Auth{ID: "auth-1"} key := claudeDeviceProfileKVKey(auth, "api-key", ClaudeDeviceProfile{}) @@ -218,8 +284,8 @@ func TestResolveClaudeDeviceProfileRequiredHomeCandidateDoesNotDowngradeCachedPr if errProfile != nil { t.Fatalf("ResolveClaudeDeviceProfileRequired() error = %v", errProfile) } - if profile.UserAgent != "claude-cli/2.4.0 (external, cli)" { - t.Fatalf("UserAgent = %q, want higher cached profile", profile.UserAgent) + if profile.UserAgent != defaultClaudeFingerprintUserAgent { + t.Fatalf("UserAgent = %q, want local baseline %q", profile.UserAgent, defaultClaudeFingerprintUserAgent) } if client.setCount != 0 { t.Fatalf("KVSet count = %d, want no downgrade write", client.setCount) @@ -236,10 +302,10 @@ func TestResolveClaudeDeviceProfileRequiredHomeFailures(t *testing.T) { client *fakeClaudeDeviceProfileKVClient }{ {name: "read", client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), getErr: errors.New("get failed")}}, - {name: "lock", headers: claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: true, setNXErr: errors.New("lock failed")}}, - {name: "lock-miss", headers: claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: false}}, - {name: "reread", headers: claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: true, getErr: errors.New("re-read failed")}}, - {name: "write", headers: claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: true, setErr: errors.New("write failed")}}, + {name: "lock", headers: claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: true, setNXErr: errors.New("lock failed")}}, + {name: "lock-miss", headers: claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: false}}, + {name: "reread", headers: claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: true, getErr: errors.New("re-read failed")}}, + {name: "write", headers: claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), client: &fakeClaudeDeviceProfileKVClient{values: make(map[string][]byte), setNXResult: true, setErr: errors.New("write failed")}}, } { t.Run(tc.name, func(t *testing.T) { useFakeClaudeDeviceProfileKVClient(t, tc.client, true, nil) @@ -317,7 +383,7 @@ func TestResolveClaudeDeviceProfileRequiredNonHomeKeepsLocalCache(t *testing.T) auth := &cliproxyauth.Auth{ID: "auth-1"} cfg := &config.Config{} - first, errFirst := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders("claude-cli/2.2.0 (external, cli)"), cfg) + first, errFirst := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", claudeDeviceHeaders(defaultClaudeFingerprintUserAgent), cfg) if errFirst != nil { t.Fatalf("ResolveClaudeDeviceProfileRequired() first error = %v", errFirst) } diff --git a/internal/runtime/executor/helps/claude_diagnostics.go b/internal/runtime/executor/helps/claude_diagnostics.go index 47520f17..7dc8b836 100644 --- a/internal/runtime/executor/helps/claude_diagnostics.go +++ b/internal/runtime/executor/helps/claude_diagnostics.go @@ -26,17 +26,18 @@ var claudeDiagnosticsState = struct { lastCleanup time.Time }{entries: make(map[string]claudeDiagnosticsEntry)} -// BeginClaudeDiagnostics starts one request generation for a credential and -// Claude conversation. It returns the last successfully completed upstream -// message ID, if any. Only a SHA-256 digest of the credential and session is -// retained as the cache key. -func BeginClaudeDiagnostics(apiKey, sessionID string) (key string, sequence uint64, previousMessageID string) { - apiKey = strings.TrimSpace(apiKey) +// BeginClaudeDiagnostics starts one request generation for a stable credential +// identity and Claude conversation. It returns the last successfully completed +// upstream message ID, if any. Only a SHA-256 digest of the credential identity +// and session is retained as the cache key, so access-token rotation does not +// interrupt continuity. +func BeginClaudeDiagnostics(credentialIdentity, sessionID string) (key string, sequence uint64, previousMessageID string) { + credentialIdentity = strings.TrimSpace(credentialIdentity) sessionID = strings.TrimSpace(sessionID) - if apiKey == "" || sessionID == "" { + if credentialIdentity == "" || sessionID == "" { return "", 0, "" } - digest := sha256.Sum256([]byte(apiKey + "\x00" + sessionID)) + digest := sha256.Sum256([]byte(credentialIdentity + "\x00" + sessionID)) key = hex.EncodeToString(digest[:]) now := time.Now() diff --git a/internal/runtime/executor/helps/cloak_utils.go b/internal/runtime/executor/helps/cloak_utils.go index b6509ee2..3c8104f7 100644 --- a/internal/runtime/executor/helps/cloak_utils.go +++ b/internal/runtime/executor/helps/cloak_utils.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" ) -var claudeMetadataDeviceIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) +var claudeMetadataDeviceIDPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) type claudeMetadataUserID struct { DeviceID string `json:"device_id"`