diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 3c9f5089..3c028cf1 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -8,16 +8,49 @@ import ( ) // Claude Code 客户端相关常量 +// +// 反编译 @anthropic-ai/claude-code 2.1.145(Bun 1.3.14 打包)确认的真实指纹: +// VERSION = 2.1.145 +// SDK 内嵌 = @anthropic-ai/sdk@0.94.0 +// BUILD_TIME = 2026-05-19T01:36:35Z +// GIT_SHA = daa4c3755d45ab0cf97bb41db8c03bd2dfd2ff5f +// X-Stainless-Runtime = "node" (Bun 经 globalThis.process 检测为 node) +// X-Stainless-Runtime-Version = globalThis.process.version (Bun Node-compat 字符串) -// DefaultCLIVersion 是当前模拟的 Claude CLI 版本 +// ClaudeCodeBundle 描述当前 sub2api 对外伪装的 Claude Code CLI 构建快照。 +// 每次升级目标 CLI 版本时,更新 DefaultBundle 即可。 +type ClaudeCodeBundle struct { + CLIVersion string + SDKVersion string + BuildDate string + GitSHA string + RuntimeVersion string + DefaultOS string + DefaultArch string +} + +// DefaultBundle 是 sub2api 对外伪装的 Claude Code CLI 构建快照。 +// 当 Claude Code 发新版且抓包确认指纹变化时,更新此处常量并同步测试断言。 +var DefaultBundle = ClaudeCodeBundle{ + CLIVersion: "2.1.145", + SDKVersion: "0.94.0", + BuildDate: "2026-05-19T01:36:35Z", + GitSHA: "daa4c3755d45ab0cf97bb41db8c03bd2dfd2ff5f", + RuntimeVersion: "v24.3.0", + DefaultOS: "MacOS", + DefaultArch: "arm64", +} + +// 所有 Default* 变量从 DefaultBundle 派生。请勿在此包外直接修改它们, +// 改用 ApplyFingerprintOverrides 保持 bundle 与派生变量一致。 var ( - DefaultCLIVersion = "2.1.104" + DefaultCLIVersion = DefaultBundle.CLIVersion DefaultStainlessLang = "js" - DefaultStainlessPackageVersion = "0.81.0" - DefaultStainlessOS = "MacOS" - DefaultStainlessArch = "arm64" + DefaultStainlessPackageVersion = DefaultBundle.SDKVersion + DefaultStainlessOS = DefaultBundle.DefaultOS + DefaultStainlessArch = DefaultBundle.DefaultArch DefaultStainlessRuntime = "node" - DefaultStainlessRuntimeVersion = "v24.3.0" + DefaultStainlessRuntimeVersion = DefaultBundle.RuntimeVersion DefaultStainlessRetryCount = "0" DefaultStainlessTimeout = "600" DefaultXApp = "cli" @@ -211,30 +244,46 @@ func AttributionHeaderDisabled() bool { // Beta header 常量 // -// 这里的常量对齐真实 Claude Code CLI 的最新流量(截至 2026-04)。 -// 选型参考:与 Parrot (src/transform/cc_mimicry.py) 的 BETAS 保持一致, +// 这里的常量对齐真实 Claude Code CLI 的最新流量(截至 2026-05,2.1.145 抓包)。 +// 选型来源:反编译 2.1.145 binary 的内部 capability 注册表(JM("name", "beta-X-Y-Z"))。 // 原因:Anthropic 上游会基于 anthropic-beta 的完整集合判定请求来源; // 缺少任何"官方 Claude Code 请求才会带"的 beta,都会被降级到第三方额度, // 对应报错:`Third-party apps now draw from your extra usage, not your plan limits.` const ( - BetaOAuth = "oauth-2025-04-20" - BetaClaudeCode = "claude-code-20250219" - BetaInterleavedThinking = "interleaved-thinking-2025-05-14" - BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" - BetaTokenCounting = "token-counting-2024-11-01" - BetaContext1M = "context-1m-2025-08-07" - BetaFastMode = "fast-mode-2026-02-01" - // 新增(对齐官方 CLI 2.1.9x 以来的流量) + BetaOAuth = "oauth-2025-04-20" + BetaClaudeCode = "claude-code-20250219" + BetaInterleavedThinking = "interleaved-thinking-2025-05-14" + BetaTokenCounting = "token-counting-2024-11-01" + BetaContext1M = "context-1m-2025-08-07" + BetaFastMode = "fast-mode-2026-02-01" BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" BetaEffort = "effort-2025-11-24" BetaRedactThinking = "redact-thinking-2026-02-12" BetaContextManagement = "context-management-2025-06-27" BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" BetaTaskBudgets = "task-budgets-2026-03-13" - BetaTokenEfficientTools = "token-efficient-tools-2026-03-28" BetaStructuredOutputs = "structured-outputs-2025-12-15" BetaAdvisor = "advisor-tool-2026-03-01" BetaWebSearch = "web-search-2025-03-05" + + // 2026-05 已 GA 的 beta,保留常量供 client 显式传入时 merge 使用, + // 但 sub2api 默认不再注入到 OAuth/API-key mimic header 中。 + BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" + BetaTokenEfficientTools = "token-efficient-tools-2025-02-19" // 修正原 2026-03-28 错日期 + + // 2.1.145 反编译新增(对齐官方 CLI 2.1.1xx 以来的流量) + BetaAdvancedToolUse = "advanced-tool-use-2025-11-20" + BetaToolSearchTool = "tool-search-tool-2025-10-19" + BetaMCPServers = "mcp-servers-2025-12-04" + BetaMCPClient = "mcp-client-2025-11-20" + BetaMidConversationSystem = "mid-conversation-system-2026-04-07" + BetaAFKMode = "afk-mode-2026-01-31" + BetaCacheDiagnosis = "cache-diagnosis-2026-04-07" + BetaContextHint = "context-hint-2026-04-09" + BetaEnvironments = "environments-2025-11-01" + BetaManagedAgents = "managed-agents-2026-04-01" + BetaSkills = "skills-2025-10-02" + BetaCompact = "compact-2026-01-12" ) // DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。 @@ -243,7 +292,8 @@ var DroppedBetas = []string{} // DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header(OAuth 账号,不含 context-1m) // 使用 GetOAuthBetaHeader(modelID) 获取含 context-1m 的 model-aware 版本。 -const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort +// 已移除 GA 的 BetaFineGrainedToolStreaming。 +const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort // MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header(OAuth,不含 context-1m) // @@ -264,13 +314,14 @@ const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking + "," + BetaEf // APIKeyBetaHeader API-key 账号使用的 anthropic-beta header(不含 oauth / context-1m) // 使用 GetAPIKeyBetaHeader(modelID) 获取含 context-1m 的 model-aware 版本。 -const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + "," + BetaEffort + "," + BetaPromptCachingScope +// 已移除 GA 的 BetaFineGrainedToolStreaming。 +const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaEffort + "," + BetaPromptCachingScope // APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不含 oauth / claude-code) const APIKeyHaikuBetaHeader = BetaInterleavedThinking + "," + BetaEffort // ModelSupports1M 判断模型是否支持 1M context window。 -// 与 claude-code-2.1.104 bundle 中 modelSupports1M 逻辑保持一致: +// 与 Claude Code CLI bundle 中 modelSupports1M 逻辑保持一致(截至 2.1.145 反编译): // // claude-sonnet-4 系列 和 claude-opus-4-6 支持 1M context。 func ModelSupports1M(modelID string) bool { @@ -280,21 +331,23 @@ func ModelSupports1M(modelID string) bool { // GetOAuthBetaHeader 返回 OAuth 账号的 beta header。 // 仅当模型支持 1M context 时才包含 context-1m-2025-08-07。 +// 已移除 GA 的 BetaFineGrainedToolStreaming。 func GetOAuthBetaHeader(modelID string) string { if ModelSupports1M(modelID) { - return BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + "," + BetaContext1M + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort + return BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaContext1M + "," + BetaRedactThinking + "," + BetaContextManagement + "," + BetaPromptCachingScope + "," + BetaEffort } return DefaultBetaHeader } // GetAPIKeyBetaHeader 返回 API-key 账号的 beta header。 // 仅当模型支持 1M context 时才包含 context-1m-2025-08-07。 +// 已移除 GA 的 BetaFineGrainedToolStreaming。 func GetAPIKeyBetaHeader(modelID string) string { if strings.Contains(strings.ToLower(modelID), "haiku") { return APIKeyHaikuBetaHeader } if ModelSupports1M(modelID) { - return BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + "," + BetaContext1M + "," + BetaEffort + "," + BetaPromptCachingScope + return BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaContext1M + "," + BetaEffort + "," + BetaPromptCachingScope } return APIKeyBetaHeader } @@ -307,45 +360,62 @@ const DefaultCacheControlTTL = "5m" // CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。 // 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。 // 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。 -const CLICurrentVersion = "2.1.92" +// +// 从 var 改为派生自 DefaultBundle.CLIVersion,避免硬编码漂移。 +var CLICurrentVersion = DefaultBundle.CLIVersion -// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, +// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 2.1.145 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 -// 顺序与真实 CLI 抓包一致。 +// 顺序基于反编译 2.1.145 binary 中的 capability 注册顺序,与真实 CLI 抓包匹配。 // // 使用建议: // - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。 // - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。 // - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。 // - 不默认加入 redact-thinking,避免上游抹除 thinking 内容;客户端显式传入时由合并逻辑保留。 +// - 不加入已 GA 的 BetaFineGrainedToolStreaming / BetaTokenEfficientTools; +// 这些常量仍保留供客户端显式 merge 使用。 func FullClaudeCodeMimicryBetas() []string { return []string{ + // 核心身份 token BetaClaudeCode, BetaOAuth, + // 思考与工具 BetaInterleavedThinking, + BetaAdvancedToolUse, + BetaToolSearchTool, + // MCP + BetaMCPClient, + BetaMCPServers, + // 上下文与缓存 BetaPromptCachingScope, - BetaEffort, - BetaContextManagement, BetaExtendedCacheTTL, + BetaContextManagement, + BetaContextHint, + BetaCacheDiagnosis, + // 系统提示与会话 + BetaMidConversationSystem, + BetaCompact, + // 工作流 + BetaEffort, + BetaTaskBudgets, + BetaAFKMode, + // 高级特性 + BetaSkills, + BetaManagedAgents, + BetaEnvironments, + BetaAdvisor, } } // DefaultHeaders 是 Claude Code 客户端默认请求头。 -var DefaultHeaders = map[string]string{ - // Keep these in sync with recent Claude CLI traffic to reduce the chance - // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 - "User-Agent": "claude-cli/2.1.92 (external, cli)", - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.70.0", - "X-Stainless-OS": "Linux", - "X-Stainless-Arch": "arm64", - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.13.0", - "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "600", - "X-App": "cli", - "Anthropic-Dangerous-Direct-Browser-Access": "true", +// +// 由 init() 在包加载时从 DefaultBundle 派生构造,避免与 DefaultBundle 漂移。 +// 注:通过 ApplyFingerprintOverrides 更新指纹后,本变量会被重新构造。 +var DefaultHeaders map[string]string + +func init() { + DefaultHeaders = buildDefaultHeaders(DefaultDeviceProfile()) } // Model 表示一个 Claude 模型 @@ -444,22 +514,36 @@ func DenormalizeModelID(id string) string { return id } -// ApplyFingerprintOverrides 用配置覆盖默认指纹值(每个实例可设不同值) +// ApplyFingerprintOverrides 用配置覆盖默认指纹值(每个实例可设不同值)。 +// +// 同步更新 DefaultBundle 与所有派生变量、DefaultHeaders、CLICurrentVersion, +// 确保单一事实源。 func ApplyFingerprintOverrides(cliVersion, pkgVersion, runtimeVersion, os_, arch string) { if cliVersion != "" { - DefaultCLIVersion = strings.TrimSpace(cliVersion) + v := strings.TrimSpace(cliVersion) + DefaultBundle.CLIVersion = v + DefaultCLIVersion = v + CLICurrentVersion = v } if pkgVersion != "" { - DefaultStainlessPackageVersion = strings.TrimSpace(pkgVersion) + v := strings.TrimSpace(pkgVersion) + DefaultBundle.SDKVersion = v + DefaultStainlessPackageVersion = v } if runtimeVersion != "" { - DefaultStainlessRuntimeVersion = strings.TrimSpace(runtimeVersion) + v := strings.TrimSpace(runtimeVersion) + DefaultBundle.RuntimeVersion = v + DefaultStainlessRuntimeVersion = v } if os_ != "" { - DefaultStainlessOS = strings.TrimSpace(os_) + v := strings.TrimSpace(os_) + DefaultBundle.DefaultOS = v + DefaultStainlessOS = v } if arch != "" { - DefaultStainlessArch = strings.TrimSpace(arch) + v := strings.TrimSpace(arch) + DefaultBundle.DefaultArch = v + DefaultStainlessArch = v } DefaultHeaders = buildDefaultHeaders(DefaultDeviceProfile()) } diff --git a/backend/internal/pkg/claude/constants_test.go b/backend/internal/pkg/claude/constants_test.go index b44ca96a..fcc15842 100644 --- a/backend/internal/pkg/claude/constants_test.go +++ b/backend/internal/pkg/claude/constants_test.go @@ -1,6 +1,65 @@ package claude -import "testing" +import ( + "strings" + "testing" +) + +// TestDefaultBundleVersions verifies the active Claude Code bundle snapshot +// matches the reverse-engineered 2.1.145 binary. Bump this when DefaultBundle +// is upgraded to a newer CLI release. +func TestDefaultBundleVersions(t *testing.T) { + if got := DefaultBundle.CLIVersion; got != "2.1.145" { + t.Fatalf("DefaultBundle.CLIVersion = %q, want %q", got, "2.1.145") + } + if got := DefaultBundle.SDKVersion; got != "0.94.0" { + t.Fatalf("DefaultBundle.SDKVersion = %q, want %q", got, "0.94.0") + } +} + +// TestDefaultBundleDrivesLegacyExports ensures all legacy exported vars stay +// in sync with DefaultBundle (single source of truth). +func TestDefaultBundleDrivesLegacyExports(t *testing.T) { + if DefaultCLIVersion != DefaultBundle.CLIVersion { + t.Fatalf("DefaultCLIVersion = %q, want %q (from DefaultBundle)", DefaultCLIVersion, DefaultBundle.CLIVersion) + } + if DefaultStainlessPackageVersion != DefaultBundle.SDKVersion { + t.Fatalf("DefaultStainlessPackageVersion = %q, want %q (from DefaultBundle)", DefaultStainlessPackageVersion, DefaultBundle.SDKVersion) + } + if DefaultStainlessRuntimeVersion != DefaultBundle.RuntimeVersion { + t.Fatalf("DefaultStainlessRuntimeVersion = %q, want %q (from DefaultBundle)", DefaultStainlessRuntimeVersion, DefaultBundle.RuntimeVersion) + } + if DefaultStainlessOS != DefaultBundle.DefaultOS { + t.Fatalf("DefaultStainlessOS = %q, want %q (from DefaultBundle)", DefaultStainlessOS, DefaultBundle.DefaultOS) + } + if DefaultStainlessArch != DefaultBundle.DefaultArch { + t.Fatalf("DefaultStainlessArch = %q, want %q (from DefaultBundle)", DefaultStainlessArch, DefaultBundle.DefaultArch) + } + if CLICurrentVersion != DefaultBundle.CLIVersion { + t.Fatalf("CLICurrentVersion = %q, want %q (from DefaultBundle)", CLICurrentVersion, DefaultBundle.CLIVersion) + } +} + +// TestDefaultHeadersDrivenByBundle ensures DefaultHeaders map carries the +// values from DefaultBundle, not stale hard-coded literals. +func TestDefaultHeadersDrivenByBundle(t *testing.T) { + if got := DefaultHeaders["X-Stainless-Package-Version"]; got != DefaultBundle.SDKVersion { + t.Fatalf("DefaultHeaders[X-Stainless-Package-Version] = %q, want %q", got, DefaultBundle.SDKVersion) + } + if got := DefaultHeaders["X-Stainless-Runtime-Version"]; got != DefaultBundle.RuntimeVersion { + t.Fatalf("DefaultHeaders[X-Stainless-Runtime-Version] = %q, want %q", got, DefaultBundle.RuntimeVersion) + } + if got := DefaultHeaders["X-Stainless-OS"]; got != DefaultBundle.DefaultOS { + t.Fatalf("DefaultHeaders[X-Stainless-OS] = %q, want %q", got, DefaultBundle.DefaultOS) + } + if got := DefaultHeaders["X-Stainless-Arch"]; got != DefaultBundle.DefaultArch { + t.Fatalf("DefaultHeaders[X-Stainless-Arch] = %q, want %q", got, DefaultBundle.DefaultArch) + } + ua := DefaultHeaders["User-Agent"] + if !strings.Contains(ua, "claude-cli/"+DefaultBundle.CLIVersion) { + t.Fatalf("DefaultHeaders[User-Agent] = %q, want substring %q", ua, "claude-cli/"+DefaultBundle.CLIVersion) + } +} func TestApplyFingerprintOverridesUpdatesSharedDefaults(t *testing.T) { t.Setenv("CLAUDE_CODE_ENTRYPOINT", "") @@ -74,7 +133,7 @@ func TestDefaultUserAgentReflectsLocalRuntimeDescriptors(t *testing.T) { t.Setenv("CLAUDE_CODE_WORKLOAD", "cron") got := DefaultUserAgent() - want := "claude-cli/2.1.104 (external, sdk-cli, agent-sdk/0.0.77, client-app/cron-daemon, workload/cron)" + want := "claude-cli/2.1.145 (external, sdk-cli, agent-sdk/0.0.77, client-app/cron-daemon, workload/cron)" if got != want { t.Fatalf("DefaultUserAgent() = %q, want %q", got, want) } @@ -86,7 +145,7 @@ func TestDetailedCodeUserAgentReflectsEntrypointAndSDK(t *testing.T) { t.Setenv("CLAUDE_AGENT_SDK_CLIENT_APP", "desktop") got := DetailedCodeUserAgent() - want := "claude-code/2.1.104 (remote, agent-sdk/0.0.77, client-app/desktop)" + want := "claude-code/2.1.145 (remote, agent-sdk/0.0.77, client-app/desktop)" if got != want { t.Fatalf("DetailedCodeUserAgent() = %q, want %q", got, want) } diff --git a/backend/internal/pkg/claudemask/mask.go b/backend/internal/pkg/claudemask/mask.go index db26bb82..218676a9 100644 --- a/backend/internal/pkg/claudemask/mask.go +++ b/backend/internal/pkg/claudemask/mask.go @@ -24,6 +24,9 @@ var SuspiciousHeaders = []string{ } // RequiredNodeHeaders 是 Node.js Claude Code 客户端必须有的头 +// +// 来源:反编译 Claude Code 2.1.145 binary,确认这些头在每个 /v1/messages +// 与 /v1/messages/count_tokens 请求中都会被 SDK 强制设置。 var RequiredNodeHeaders = map[string]bool{ "User-Agent": true, "X-Stainless-Lang": true, @@ -33,6 +36,9 @@ var RequiredNodeHeaders = map[string]bool{ "X-Stainless-OS": true, "X-Stainless-Arch": true, "anthropic-version": true, + // 2.1.145 强制:SDK 内 `"X-Claude-Code-Session-Id":y_()`, + // 上游用其识别会话;缺失会被判第三方。 + "X-Claude-Code-Session-Id": true, } // ValidateNodeEmulation 验证请求是否正确伪装为 Node.js 客户端 diff --git a/backend/internal/pkg/claudemask/mask_test.go b/backend/internal/pkg/claudemask/mask_test.go index 442428b6..acc573f9 100644 --- a/backend/internal/pkg/claudemask/mask_test.go +++ b/backend/internal/pkg/claudemask/mask_test.go @@ -19,6 +19,8 @@ func TestValidateNodeEmulation_ValidRequest(t *testing.T) { req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) + // 2.1.145 强制:X-Claude-Code-Session-Id (UUID) + req.Header.Set("X-Claude-Code-Session-Id", "01970000-0000-7000-8000-000000000001") isValid, errors := ValidateNodeEmulation(req) @@ -44,8 +46,39 @@ func TestValidateNodeEmulation_MissingHeaders(t *testing.T) { if len(errors) == 0 { t.Error("expected validation errors") } - if len(errors) < 7 { - t.Errorf("expected at least 7 missing headers, got %d errors", len(errors)) + if len(errors) < 8 { + t.Errorf("expected at least 8 missing headers, got %d errors", len(errors)) + } +} + +// TestValidateNodeEmulation_MissingSessionID 验证 X-Claude-Code-Session-Id +// 是强制头:缺失则验证失败。 +func TestValidateNodeEmulation_MissingSessionID(t *testing.T) { + req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + + // 设置所有 Stainless 头,但缺 session-id + req.Header.Set("User-Agent", claude.DefaultUserAgent()) + req.Header.Set("X-Stainless-Lang", claude.DefaultStainlessLang) + req.Header.Set("X-Stainless-Runtime", claude.DefaultStainlessRuntime) + req.Header.Set("X-Stainless-Runtime-Version", claude.DefaultStainlessRuntimeVersion) + req.Header.Set("X-Stainless-Package-Version", claude.DefaultStainlessPackageVersion) + req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) + req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) + req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) + + isValid, errs := ValidateNodeEmulation(req) + if isValid { + t.Fatal("expected invalid due to missing X-Claude-Code-Session-Id") + } + foundSessionErr := false + for _, e := range errs { + if e == "missing required header: X-Claude-Code-Session-Id" { + foundSessionErr = true + break + } + } + if !foundSessionErr { + t.Fatalf("expected X-Claude-Code-Session-Id error, got: %v", errs) } } @@ -145,6 +178,8 @@ func TestValidateAndClean(t *testing.T) { req.Header.Set("X-Stainless-OS", claude.DefaultStainlessOS) req.Header.Set("X-Stainless-Arch", claude.DefaultStainlessArch) req.Header.Set("anthropic-version", claude.DefaultAnthropicVersion) + // 2.1.145 强制:X-Claude-Code-Session-Id + req.Header.Set("X-Claude-Code-Session-Id", "01970000-0000-7000-8000-000000000002") isValid, errors := ValidateAndClean(req) diff --git a/backend/internal/service/claude_code_session_id.go b/backend/internal/service/claude_code_session_id.go new file mode 100644 index 00000000..f087c004 --- /dev/null +++ b/backend/internal/service/claude_code_session_id.go @@ -0,0 +1,73 @@ +package service + +import ( + "net/http" + + "github.com/google/uuid" + "github.com/tidwall/gjson" +) + +// ensureClaudeCodeSessionID 确保 X-Claude-Code-Session-Id header 被合理填充。 +// +// 真实 Claude Code 2.1.145 CLI 在 SDK 内会始终设置 X-Claude-Code-Session-Id 为 +// 当前 CLI 会话的 UUID(源码:`"X-Claude-Code-Session-Id":y_()`)。上游若发现 +// 该头缺失,可能将请求识别为非官方 Claude Code 第三方调用。 +// +// 行为按 tokenType / mimicClaudeCode 分两条路径: +// +// OAuth mimic 路径 (tokenType == "oauth" && mimicClaudeCode): +// 1. body 中 metadata.user_id 派生的 SessionID 是合法 UUID → canonicalize 写入 +// 2. 请求 header 中已有合法 UUID → canonicalize 保留 +// 3. 否则 → 兜底生成 UUID +// +// API key 透传 / 非 mimic 路径: +// - 不从 body 合成 header(避免污染客户端原始语义) +// - 但若客户端在 header 中传入了 X-Claude-Code-Session-Id: +// 合法 UUID → canonicalize 保留 +// 非法值 → 删除(不向上游转发恶意值,符合 UUID 校验承诺) +// - 不兜底生成 +// +// 安全说明:metadata.user_id 由客户端控制,ParseMetadataUserID 的正则仅约束字符集, +// 不保证 UUID 结构。因此所有写入 header 的 session id 都必须经 uuid.Parse 校验。 +func ensureClaudeCodeSessionID(req *http.Request, body []byte, tokenType string, mimicClaudeCode bool) { + if req == nil { + return + } + if req.Header == nil { + req.Header = make(http.Header) + } + + isOAuthMimic := tokenType == "oauth" && mimicClaudeCode + + // OAuth mimic 路径:从 metadata 派生(仅在 mimic 场景写 header)。 + if isOAuthMimic { + if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { + if parsed := ParseMetadataUserID(uid); parsed != nil { + if id, err := uuid.Parse(parsed.SessionID); err == nil { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", id.String()) + return + } + } + } + } + + // 处理客户端已传入的 header:合法 → canonicalize,非法 → 删除。 + // 这条规则对所有路径生效,避免恶意非 UUID 值向上游透传。 + if existing := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); existing != "" { + if id, err := uuid.Parse(existing); err == nil { + canonical := id.String() + if canonical != existing { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", canonical) + } + return + } + // 非法 UUID:删除避免透传 + req.Header.Del("X-Claude-Code-Session-Id") + } + + // OAuth mimic 兜底生成(仅 mimic 场景;API key 不污染)。 + // uuid.NewString() 走 crypto/rand。 + if isOAuthMimic { + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", uuid.NewString()) + } +} diff --git a/backend/internal/service/claude_code_session_id_test.go b/backend/internal/service/claude_code_session_id_test.go new file mode 100644 index 00000000..d5c9023b --- /dev/null +++ b/backend/internal/service/claude_code_session_id_test.go @@ -0,0 +1,200 @@ +package service + +import ( + "net/http" + "testing" + + "github.com/google/uuid" +) + +const ( + testValidUUID = "01970000-0000-7000-8000-000000000001" + testValidUUIDAlt = "01970000-0000-7000-8000-000000000002" +) + +func newReq(t *testing.T) *http.Request { + t.Helper() + req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + return req +} + +func TestEnsureClaudeCodeSessionID_NilRequest(t *testing.T) { + // Should not panic. + ensureClaudeCodeSessionID(nil, nil, "oauth", true) +} + +func TestEnsureClaudeCodeSessionID_FromMetadataJSON(t *testing.T) { + req := newReq(t) + body := []byte(`{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\",\"session_id\":\"` + testValidUUID + `\"}"}}`) + ensureClaudeCodeSessionID(req, body, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != testValidUUID { + t.Fatalf("session_id = %q, want %q", got, testValidUUID) + } +} + +func TestEnsureClaudeCodeSessionID_FromMetadataLegacy(t *testing.T) { + req := newReq(t) + // legacy format: user_{64hex}_account_{uuid}_session_{uuid} + dev := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + acc := "11111111-2222-3333-4444-555555555555" + uid := "user_" + dev + "_account_" + acc + "_session_" + testValidUUID + body := []byte(`{"metadata":{"user_id":"` + uid + `"}}`) + ensureClaudeCodeSessionID(req, body, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != testValidUUID { + t.Fatalf("session_id = %q, want %q", got, testValidUUID) + } +} + +// 安全测试:metadata.user_id 中的 session_id 不是合法 UUID 时, +// 必须 fallback 到 OAuth UUID 兜底,而不是写入恶意值。 +func TestEnsureClaudeCodeSessionID_RejectsMalformedMetadataUUID(t *testing.T) { + req := newReq(t) + // session_id 字段是 36 字符但非 UUID 格式(凑数 hex+dash) + bogus := "abcdefab-0000-0000-0000-not-a-real-uuid" + body := []byte(`{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\",\"session_id\":\"` + bogus + `\"}"}}`) + ensureClaudeCodeSessionID(req, body, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got == bogus { + t.Fatalf("malformed metadata session_id was written verbatim: %q", got) + } + if got == "" { + t.Fatalf("expected OAuth mimic to fallback to UUID, got empty") + } + if _, err := uuid.Parse(got); err != nil { + t.Fatalf("fallback session_id is not a valid UUID: %q (err=%v)", got, err) + } +} + +func TestEnsureClaudeCodeSessionID_PreservesExistingValidHeader(t *testing.T) { + req := newReq(t) + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", testValidUUID) + ensureClaudeCodeSessionID(req, nil, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != testValidUUID { + t.Fatalf("session_id = %q, want preserved %q", got, testValidUUID) + } +} + +func TestEnsureClaudeCodeSessionID_OverwritesInvalidHeader(t *testing.T) { + req := newReq(t) + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", "not-a-uuid") + ensureClaudeCodeSessionID(req, nil, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got == "not-a-uuid" { + t.Fatalf("invalid header was not overwritten: %q", got) + } + if _, err := uuid.Parse(got); err != nil { + t.Fatalf("fallback session_id is not a valid UUID: %q (err=%v)", got, err) + } +} + +func TestEnsureClaudeCodeSessionID_OAuthMimicFallback(t *testing.T) { + req := newReq(t) + ensureClaudeCodeSessionID(req, nil, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got == "" { + t.Fatalf("expected OAuth mimic fallback to set X-Claude-Code-Session-Id") + } + if _, err := uuid.Parse(got); err != nil { + t.Fatalf("fallback session_id is not a valid UUID: %q (err=%v)", got, err) + } +} + +// API key passthrough 路径必须不被污染:缺失 session-id 时不强制生成。 +func TestEnsureClaudeCodeSessionID_DoesNotPolluteAPIKey(t *testing.T) { + req := newReq(t) + ensureClaudeCodeSessionID(req, nil, "api_key", false) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != "" { + t.Fatalf("API key path should NOT auto-generate session-id, got %q", got) + } +} + +// API key 路径即使 body 中有合法 metadata.user_id,也不应该派生 session-id 头。 +// 这是 fresh code review 发现的 C1 修复:保护客户端原始语义。 +func TestEnsureClaudeCodeSessionID_APIKeyIgnoresMetadata(t *testing.T) { + req := newReq(t) + // 客户端传入合法的 metadata.user_id,但 tokenType=api_key + body := []byte(`{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\",\"session_id\":\"` + testValidUUID + `\"}"}}`) + ensureClaudeCodeSessionID(req, body, "api_key", false) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != "" { + t.Fatalf("API key path must NOT derive session-id from metadata, got %q", got) + } +} + +// OAuth 但非 mimic 模式也不应该从 metadata 派生 header。 +func TestEnsureClaudeCodeSessionID_OAuthNonMimicIgnoresMetadata(t *testing.T) { + req := newReq(t) + body := []byte(`{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\",\"session_id\":\"` + testValidUUID + `\"}"}}`) + ensureClaudeCodeSessionID(req, body, "oauth", false) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != "" { + t.Fatalf("Non-mimic OAuth must NOT derive session-id from metadata, got %q", got) + } +} + +// API key 路径若客户端传入了非法 UUID header,必须删除避免向上游透传。 +// 这是 C2 修复:UUID 校验承诺要对所有路径生效。 +func TestEnsureClaudeCodeSessionID_APIKeyDeletesInvalidHeader(t *testing.T) { + req := newReq(t) + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", "not-a-uuid") + ensureClaudeCodeSessionID(req, nil, "api_key", false) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != "" { + t.Fatalf("API key path must delete invalid header, got %q", got) + } +} + +// API key 路径若客户端传入了合法 UUID header,规范化保留(不删除)。 +func TestEnsureClaudeCodeSessionID_APIKeyPreservesValidHeader(t *testing.T) { + req := newReq(t) + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", testValidUUID) + ensureClaudeCodeSessionID(req, nil, "api_key", false) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != testValidUUID { + t.Fatalf("API key path must preserve valid client header, got %q want %q", got, testValidUUID) + } +} + +// OAuth 但非 mimic 场景:保留客户端原始语义,不自动生成。 +func TestEnsureClaudeCodeSessionID_OAuthNonMimicDoesNotForce(t *testing.T) { + req := newReq(t) + ensureClaudeCodeSessionID(req, nil, "oauth", false) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + if got != "" { + t.Fatalf("non-mimic OAuth should not auto-generate, got %q", got) + } +} + +// 验证不同 UUID 输入会被规范化为 canonical 小写形式。 +func TestEnsureClaudeCodeSessionID_CanonicalForm(t *testing.T) { + req := newReq(t) + // 大写 UUID 输入 + upper := "01970000-0000-7000-8000-ABCDEF000003" + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", upper) + ensureClaudeCodeSessionID(req, nil, "oauth", true) + + got := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id") + want := "01970000-0000-7000-8000-abcdef000003" + if got != want { + t.Fatalf("session_id = %q, want canonical %q", got, want) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index e25e6d82..5a00bbc7 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6253,13 +6253,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } // X-Claude-Code-Session-Id 头处理: - // Claude Code 主 API 客户端会始终发送 X-Claude-Code-Session-Id。 - // 对于 mimic / 转发场景,只要 body 中 metadata.user_id 可解析,就主动注入并同步该头。 - if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { - if parsed := ParseMetadataUserID(uid); parsed != nil { - setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) - } - } + // Claude Code 2.1.145 SDK 内强制设置该头(`"X-Claude-Code-Session-Id":y_()`)。 + // 优先取 metadata.user_id 中的 sessionID;OAuth mimic 场景缺失时兜底 UUID, + // 避免上游基于该头缺失判定为第三方调用。 + ensureClaudeCodeSessionID(req, body, tokenType, mimicClaudeCode) // x-client-request-id: 真实 CLI 每个请求生成新 UUID(仅 1P)。 if getHeaderRaw(req.Header, "x-client-request-id") == "" && tokenType == "oauth" { @@ -9462,13 +9459,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } - // Claude Code 主 API 客户端会始终发送 X-Claude-Code-Session-Id。 - // 对于 mimic / 转发场景,只要 body 中 metadata.user_id 可解析,就主动注入并同步该头。 - if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" { - if parsed := ParseMetadataUserID(uid); parsed != nil { - setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID) - } - } + // X-Claude-Code-Session-Id 头处理(count_tokens 路径): + // 与 messages 路径保持同样逻辑,OAuth mimic 场景缺失时兜底 UUID。 + ensureClaudeCodeSessionID(req, body, tokenType, mimicClaudeCode) // x-client-request-id(count_tokens 路径) if getHeaderRaw(req.Header, "x-client-request-id") == "" && tokenType == "oauth" {