diff --git a/admin/claude_export.go b/admin/claude_export.go index b559bf36..a345c24a 100644 --- a/admin/claude_export.go +++ b/admin/claude_export.go @@ -273,24 +273,32 @@ func prepareClaudeTimezoneCredentialUpdateWithHeaders(row *database.AccountRow, if requestedHeaders != nil { baseHeaders = requestedHeaders } + // Keys are canonicalized up front: the generated fingerprint says + // "X-Stainless-OS" while previously persisted headers come back as + // "X-Stainless-Os", and normalizeCustomHeaders treats a case-only clash + // with different values as an error. Without this a same-timezone save + // failed whenever the freshly rolled OS differed from the stored one. + // 先统一成规范大小写:指纹生成的是 X-Stainless-OS,落库后读回是 X-Stainless-Os, + // 否则同时区保存时随机到不同 OS 就会触发"大小写重复且值冲突"。 merged := make(map[string]string) keepIdentity := requestedHeaders == nil && strings.EqualFold(strings.TrimSpace(row.GetCredential("timezone")), timezone) for name, value := range auth.GenerateClaudeFingerprint(timezone).Headers() { - merged[name] = value + merged[http.CanonicalHeaderKey(strings.TrimSpace(name))] = value } for name, value := range baseHeaders { lowerName := strings.ToLower(strings.TrimSpace(name)) + canonicalName := http.CanonicalHeaderKey(strings.TrimSpace(name)) if _, isIdentity := identity[lowerName]; isIdentity { // Keep a complete existing fingerprint stable when the operator // saves the same timezone again; a timezone change (or explicit // header patch) intentionally rotates the identity snapshot. if keepIdentity { - merged[name] = value + merged[canonicalName] = value } continue } if isClaudeSafeOperationalHeader(name) { - merged[name] = value + merged[canonicalName] = value } } normalized, err := normalizeCustomHeaders(merged) diff --git a/admin/claude_timezone_headers_test.go b/admin/claude_timezone_headers_test.go new file mode 100644 index 00000000..f8d7f94e --- /dev/null +++ b/admin/claude_timezone_headers_test.go @@ -0,0 +1,44 @@ +package admin + +import ( + "testing" + + "github.com/codex2api/database" +) + +// A Claude account whose stored identity headers use Go's canonical casing +// ("X-Stainless-Os") must survive a same-timezone save: the fresh fingerprint +// is keyed "X-Stainless-OS", and a case-insensitive clash with a different +// random OS used to fail the whole update with +// "custom_headers 包含大小写重复且值冲突的请求头: X-Stainless-Os". +func TestPrepareClaudeTimezoneUpdate_KeepsCanonicalCasedIdentityOnSameTimezone(t *testing.T) { + stored := map[string]string{ + "User-Agent": "claude-cli/2.1.259 (external, cli)", "X-App": "cli", + "X-Stainless-Arch": "x64", "X-Stainless-Lang": "js", "X-Stainless-Os": "Windows", + "X-Stainless-Package-Version": "0.65.0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v20.18.1", + } + for i := 0; i < 30; i++ { // the generated OS is random; every save must succeed + headersAny := make(map[string]interface{}, len(stored)) + for k, v := range stored { + headersAny[k] = v + } + row := &database.AccountRow{Platform: "anthropic", Credentials: map[string]interface{}{ + "upstream_type": "claude", "timezone": "Asia/Shanghai", "custom_headers": headersAny, + }} + updates := map[string]interface{}{} + applied, err := prepareClaudeTimezoneCredentialUpdateWithHeaders(row, "Asia/Shanghai", updates, nil) + if err != nil { + t.Fatalf("iteration %d: %v", i, err) + } + if !applied { + t.Fatal("update must apply to a Claude row") + } + headers, _ := updates["custom_headers"].(map[string]string) + if headers["X-Stainless-Os"] != "Windows" { + t.Fatalf("iteration %d: stored identity must win on same timezone, got %v", i, headers) + } + if _, dup := headers["X-Stainless-OS"]; dup { + t.Fatalf("iteration %d: headers must be canonical-cased only, got %v", i, headers) + } + } +}