diff --git a/backend/internal/pkg/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go index 043df2b554..89f8db33a8 100644 --- a/backend/internal/pkg/claude/cli_version_test.go +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -9,7 +9,7 @@ func TestIsSupportedCLIVersion(t *testing.T) { want bool }{ {"内置基线本身", CLICurrentVersion, true}, - {"高于基线的补丁位", "2.1.251", true}, + {"高于基线的补丁位", "2.1.259", true}, {"高于基线的次版本", "2.2.0", true}, {"高于基线的主版本", "3.0.0", true}, {"低于基线", "2.1.219", false}, @@ -41,8 +41,8 @@ func TestResolveCLIVersion(t *testing.T) { }{ {"未配置时用内置基线", "", CLICurrentVersion}, {"只有空白等同未配置", " ", CLICurrentVersion}, - {"合法覆盖生效", "2.1.251", "2.1.251"}, - {"两侧空白被裁掉", " 2.1.251 ", "2.1.251"}, + {"合法覆盖生效", "2.1.259", "2.1.259"}, + {"两侧空白被裁掉", " 2.1.259 ", "2.1.259"}, {"非法值回落基线", "not-a-version", CLICurrentVersion}, {"向下覆盖被拒", "2.0.0", CLICurrentVersion}, {"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion}, diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index df9b7613b2..319eb84dd3 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -80,7 +80,7 @@ const DefaultCacheControlTTL = "5m" // // ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。 // 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。 -const CLICurrentVersion = "2.1.220" +const CLICurrentVersion = "2.1.258" // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 diff --git a/backend/internal/pkg/claude/constants_cli_version_test.go b/backend/internal/pkg/claude/constants_cli_version_test.go new file mode 100644 index 0000000000..91bd12833f --- /dev/null +++ b/backend/internal/pkg/claude/constants_cli_version_test.go @@ -0,0 +1,70 @@ +package claude + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fable51MinCLIVersion 是上游对 claude-fable-5-1 的客户端版本闸门下限。 +// 低于该版本时上游直接 400: +// +// claude_code_version_too_old +// "Claude Code does not support this model; version 2.1.251 or newer is required." +const fable51MinCLIVersion = "2.1.251" + +// TestCLICurrentVersionMatchesDefaultUserAgent 锁定"常量与 UA 同源"这条约束。 +// 两者不一致会被 Anthropic 判为第三方客户端,且只改一处是历史上的常见疏漏, +// 所以这里从 UA 里反解版本号来比对,而不是并列两个字面量。 +func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) { + t.Parallel() + + ua := DefaultHeaders["User-Agent"] + const prefix = "claude-cli/" + require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua) + + rest := strings.TrimPrefix(ua, prefix) + version := rest + if idx := strings.IndexByte(rest, ' '); idx >= 0 { + version = rest[:idx] + } + require.Equal(t, CLICurrentVersion, version, + "DefaultHeaders User-Agent version must match CLICurrentVersion") +} + +// TestCLICurrentVersionSatisfiesFable51Gate 锁定伪装版本不低于 claude-fable-5-1 +// 的闸门下限。DefaultModels 里登记了该模型却发着更旧的版本号,等于模型清单可见 +// 但一调就 400。 +func TestCLICurrentVersionSatisfiesFable51Gate(t *testing.T) { + t.Parallel() + + require.GreaterOrEqual(t, compareSemver(t, CLICurrentVersion, fable51MinCLIVersion), 0, + "CLICurrentVersion %s is below the claude-fable-5-1 gate %s", + CLICurrentVersion, fable51MinCLIVersion) +} + +// compareSemver 比较两个三段 semver,返回 -1 / 0 / 1。 +func compareSemver(t *testing.T, a, b string) int { + t.Helper() + + as, bs := strings.Split(a, "."), strings.Split(b, ".") + require.Len(t, as, 3, "not a three-part semver: %q", a) + require.Len(t, bs, 3, "not a three-part semver: %q", b) + + for i := range as { + ai, err := strconv.Atoi(as[i]) + require.NoError(t, err, "non-numeric segment in %q", a) + bi, err := strconv.Atoi(bs[i]) + require.NoError(t, err, "non-numeric segment in %q", b) + + switch { + case ai > bi: + return 1 + case ai < bi: + return -1 + } + } + return 0 +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 2f9e9d0b3f..d117f720cc 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -28,6 +28,10 @@ var ( // /.. 之后必须紧跟空白或字符串结束。 // 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。 fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`) + + // claudeCLIUAVersionPrefixRegex 匹配 claude-cli UA 开头的 "claude-cli/x.y.z" 版本号段, + // 供版本下限抬升时就地替换版本号使用;UA 其余部分(括号内的真实客户端形态等)原样保留。 + claudeCLIUAVersionPrefixRegex = regexp.MustCompile(`(?i)^(claude-cli)/\d+\.\d+\.\d+`) ) const ( @@ -75,6 +79,38 @@ func isAcceptableFingerprintUserAgent(ua string) bool { return major <= currentMajor+maxClaudeCLIMajorVersionSkew } +// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion +// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 +// +// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置 +// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在 +// 历史值(如 claude-cli/2.1.220),客户端送来更旧的版本时 isNewerVersion 不会触发升级—— +// 仅升 CLICurrentVersion 常量对所有已有账号完全无效,上游按指纹 UA 做客户端版本闸门 +// (如 Fable 5.1 要求 >= 2.1.251)时旧指纹永远过不去。 +// +// 约束: +// - 只对 claude-cli 产品生效,其它产品一律不动,避免误伤别的合法客户端; +// - 只升不降:版本等于或高于 CLICurrentVersion(含客户端上报的更新版本)时不做任何改动; +// - 只替换 claude-cli/ 后的版本号段,UA 其余部分(如 "(external, claude-desktop-3p, +// agent-sdk/0.3.100)")原样保留,不重建整个字符串、不退化为 defaultFingerprint.UserAgent; +// - X-Stainless-* 字段不在此处理,维持调用方的既有 merge 语义。 +func floorClaudeCLIUserAgentVersion(ua string) (string, bool) { + if extractProduct(ua) != claudeCLIUserAgentProduct { + return ua, false + } + floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion + // isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua: + // ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。 + if !isNewerVersion(floorUA, ua) { + return ua, false + } + floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion) + if floored == ua { + return ua, false + } + return floored, true +} + // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)", @@ -157,12 +193,29 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID logger.LegacyPrintf("service.identity", "Replaced malformed cached fingerprint for account %d: %q -> %q", accountID, poisoned, cached.UserAgent) - } else if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { - // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值 - // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) - mergeHeadersIntoFingerprint(cached, headers) + } else { + // 客户端送来更新版本时的常规升级:merge 语义 — 仅更新请求中实际携带的字段, + // 保留缓存值,避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) + if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { + mergeHeadersIntoFingerprint(cached, headers) + needWrite = true + logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) + } + + } + + // 版本下限抬升(floor):heal 与 healthy 两条缓存命中路径共同的后置条件,与 + // 上面的客户端常规升级相互独立、二者取更新者。客户端送来更旧版本时 + // isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹就地抬到 + // 下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的客户端 + // 版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。自愈路径同样必须经过: + // 畸形缓存 + 合法但过旧的客户端 UA 首次读取就要落到下限,不能先落库一个 + // 过旧版本、等下一次读取才纠正。 + if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed { + cached.UserAgent = flooredUA needWrite = true - logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) + logger.LegacyPrintf("service.identity", + "Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA) } if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { @@ -208,7 +261,9 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin // 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。 // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { - fp.UserAgent = ua + // 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA + // 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。 + fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua) } else { fp.UserAgent = defaultFingerprint.UserAgent } diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 5206eb04f0..1a52d12554 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -105,8 +105,9 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { // 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。 // isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。 func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { + // 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。 cached := &Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", + UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", ClientID: "cid-1", UpdatedAt: time.Now().Unix(), } @@ -119,7 +120,7 @@ func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent, + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent, "真实指纹不得被哨兵版本覆盖") require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入") } @@ -133,7 +134,9 @@ func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) { }} svc := NewIdentityService(cache) - newUA := "claude-cli/2.1.223 (external, cli)" + // 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级, + // 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。 + newUA := "claude-cli/2.9.0 (external, cli)" fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) require.NoError(t, err) @@ -176,8 +179,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA)) require.NoError(t, err) - require.Equal(t, realUA, fp.UserAgent, - "真实客户端必须能从被毒化的指纹手中夺回账号身份") + // 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效: + // 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。 + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库") require.Equal(t, 1, cache.setCalls) require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID") @@ -203,9 +208,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing } // 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。 +// 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。 func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { cache := &stubIdentityCache{fingerprint: &Fingerprint{ - UserAgent: "claude-cli/2.1.220 (external, cli)", + UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", ClientID: "cid-1", UpdatedAt: time.Now().Unix(), }} @@ -217,7 +223,7 @@ func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) require.Zero(t, cache.setCalls) } diff --git a/backend/internal/service/identity_service_version_floor_test.go b/backend/internal/service/identity_service_version_floor_test.go new file mode 100644 index 0000000000..78a15627c4 --- /dev/null +++ b/backend/internal/service/identity_service_version_floor_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +// floorClaudeCLIUserAgentVersion 单元测试:版本下限抬升的各种形态。 +func TestFloorClaudeCLIUserAgentVersion(t *testing.T) { + floorUA := "claude-cli/" + claude.CLICurrentVersion + cases := []struct { + name string + ua string + want string + wantChanged bool + }{ + // 线上故障形态:历史版本低于 CLICurrentVersion,就地抬到下限。 + {"old_version_upgraded", "claude-cli/2.1.220 (external, cli)", floorUA + " (external, cli)", true}, + // 只替换版本号段,括号内的真实客户端形态原样保留。 + {"old_version_with_desktop_3p_suffix", + "claude-cli/2.1.100 (external, claude-desktop-3p, agent-sdk/0.3.100)", + floorUA + " (external, claude-desktop-3p, agent-sdk/0.3.100)", true}, + // 等于下限:不得改动。 + {"equal_to_floor", floorUA + " (external, cli)", floorUA + " (external, cli)", false}, + // 高于下限:只升不降,不得把客户端上报的更新版本压回去。 + {"newer_than_floor_not_downgraded", "claude-cli/2.9.0 (external, cli)", "claude-cli/2.9.0 (external, cli)", false}, + // 非 claude-cli 产品:一律不动。 + {"other_product_untouched", "opencode/1.2.3 (external, cli)", "opencode/1.2.3 (external, cli)", false}, + // 空串 / 畸形:一律不动。 + {"empty", "", "", false}, + {"no_version", "claude-cli (external, cli)", "claude-cli (external, cli)", false}, + {"unparseable_version", "claude-cli/abc (external, cli)", "claude-cli/abc (external, cli)", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, changed := floorClaudeCLIUserAgentVersion(tc.ua) + require.Equal(t, tc.want, got) + require.Equal(t, tc.wantChanged, changed) + }) + } +} + +// GetOrCreateFingerprint 集成行为,直接对应线上故障:缓存指纹停留在历史版本 +// 2.1.220(生产 Redis 中账号 147 的实际值),客户端送来更旧的 2.1.75。 +// 修复前:isNewerVersion 不触发、UA 形态合法不触发自愈,旧指纹被原样返回并 +// 近乎永不过期——上游按指纹 UA 做客户端版本闸门(Fable 5.1 要求 >= 2.1.251), +// 仅升 CLICurrentVersion 对存量账号完全无效。 +func TestGetOrCreateFingerprintFloorsStaleCachedUserAgent(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + StainlessPackageVersion: "0.91.1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 147, + headersWithUA("claude-cli/2.1.75 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls, "下限抬升必须持久化写回缓存") + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", cache.lastSet.UserAgent) + // X-Stainless-* 维持既有 merge 语义:客户端未携带时保留缓存中的真实值,不被下限逻辑覆盖。 + require.Equal(t, "0.91.1", fp.StainlessPackageVersion) +} + +// 缓存版本高于下限:不得被降级,也不得触发多余写入。 +func TestGetOrCreateFingerprintDoesNotTouchCacheAboveFloor(t *testing.T) { + ua := "claude-cli/2.9.0 (external, cli)" + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: ua, + ClientID: "cid-1", + StainlessPackageVersion: "0.91.1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.75 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, ua, fp.UserAgent, "高于下限的缓存版本不得被降级") + require.Zero(t, cache.setCalls) +} + +// 客户端送来高于下限的更新版本:常规升级照常生效,且不被下限压回。 +func TestGetOrCreateFingerprintClientNewerThanFloorStillWins(t *testing.T) { + newUA := "claude-cli/2.9.0 (external, cli)" + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) + + require.NoError(t, err) + require.Equal(t, newUA, fp.UserAgent, "客户端更新版本优先于下限,不得被压回") + require.Equal(t, 1, cache.setCalls) +} + +// 客户端版本高于缓存但低于下限:merge 升级后仍被抬到下限(二者取更新者)。 +func TestGetOrCreateFingerprintFloorWinsOverStaleClientUpgrade(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.223 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 首次创建路径同样受下限约束:合法但过旧的 claude-cli UA 落库时必须抬到 CLICurrentVersion。 +func TestCreateFingerprintFromHeadersFloorsOldClientUserAgent(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.75 (external, claude-desktop-3p)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, claude-desktop-3p)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +}