diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 3c7fe581..c229d340 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -256,6 +256,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RewriteMessageCacheControl: settings.RewriteMessageCacheControl, AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, OpenAICodexUserAgent: settings.OpenAICodexUserAgent, + OpenAIAllowClaudeCodeCodexPlugin: settings.OpenAIAllowClaudeCodeCodexPlugin, WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, @@ -584,6 +585,7 @@ type UpdateSettingsRequest struct { RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"` AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"` OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` + OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"` // Payment visible method routing PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` @@ -1655,6 +1657,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexUserAgent }(), + OpenAIAllowClaudeCodeCodexPlugin: func() bool { + if req.OpenAIAllowClaudeCodeCodexPlugin != nil { + return *req.OpenAIAllowClaudeCodeCodexPlugin + } + return previousSettings.OpenAIAllowClaudeCodeCodexPlugin + }(), PaymentVisibleMethodAlipaySource: func() string { if req.PaymentVisibleMethodAlipaySource != nil { return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource) @@ -2031,6 +2039,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, + OpenAIAllowClaudeCodeCodexPlugin: updatedSettings.OpenAIAllowClaudeCodeCodexPlugin, PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, @@ -2500,6 +2509,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent { changed = append(changed, "openai_codex_user_agent") } + if before.OpenAIAllowClaudeCodeCodexPlugin != after.OpenAIAllowClaudeCodeCodexPlugin { + changed = append(changed, "openai_allow_claude_code_codex_plugin") + } if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { changed = append(changed, "payment_visible_method_alipay_source") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index eecf98ac..17772a2e 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -185,6 +185,7 @@ type SystemSettings struct { RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"` AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"` OpenAICodexUserAgent string `json:"openai_codex_user_agent"` + OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"` // Web Search Emulation WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"` diff --git a/backend/internal/pkg/openai/allowed_client.go b/backend/internal/pkg/openai/allowed_client.go new file mode 100644 index 00000000..d4ca14ee --- /dev/null +++ b/backend/internal/pkg/openai/allowed_client.go @@ -0,0 +1,78 @@ +package openai + +import "strings" + +// 命名预设 ID。账号侧 codex_cli_only_allowed_clients 只能引用这些预设键, +// 具体匹配规则固化在下方 registry 中,配置只能「选择启用哪些预设」、不能自定义规则, +// 以防该白名单退化为可任意放宽的后门。 +const ( + // AllowedClientClaudeCode 对应 Claude Code CLI 的 codex 插件。 + AllowedClientClaudeCode = "claude_code" +) + +// AllowedClientEntry 描述一个被额外放行的非官方 Codex 客户端签名。 +// Originator 必须精确等值匹配(归一化后)。 +// UAContains 为必填字段:列表为空,或列表中存在任何空白 marker,均视为非法配置, +// 整体安全失败(return false);每一项都必须出现在 User-Agent 中。 +// 这确保双因子匹配不会因缺失 UA 声明而退化为仅凭可伪造的 originator 单因子放行。 +type AllowedClientEntry struct { + Originator string + UAContains []string +} + +// allowedClientRegistry 固化各命名预设的签名规则。 +// +// Claude Code codex 插件签名来源:插件以 clientInfo.name="Claude Code" 完成 app-server +// initialize 握手,codex 据此把 originator 设为 "Claude Code",User-Agent 前缀同样为 +// "Claude Code/"(两者同源)。若上游 Claude Code 插件更改 clientInfo.name,此处需同步更新。 +var allowedClientRegistry = map[string]AllowedClientEntry{ + AllowedClientClaudeCode: { + Originator: "Claude Code", + UAContains: []string{"Claude Code/"}, + }, +} + +// IsAllowedClientMatch 判断请求头是否命中给定的额外客户端签名。 +// originator 必须精确等值(归一化后);UAContains 中每一项都必须出现在 UA 中。 +// UAContains 为必填:列表为空或含任何空白 marker 均视为非法配置,整体安全失败。 +func IsAllowedClientMatch(userAgent, originator string, entry AllowedClientEntry) bool { + wantOriginator := normalizeCodexClientHeader(entry.Originator) + if wantOriginator == "" { + return false + } + if normalizeCodexClientHeader(originator) != wantOriginator { + return false + } + // 预设必须声明 UA 特征:否则将退化为仅凭可伪造的 originator 单因子匹配。 + if len(entry.UAContains) == 0 { + return false + } + ua := normalizeCodexClientHeader(userAgent) + for _, marker := range entry.UAContains { + normalizedMarker := normalizeCodexClientHeader(marker) + if normalizedMarker == "" { + // 空白 marker 让该项失去校验能力,会让双因子退化为仅 originator + // 单因子;视为非法配置,安全失败。 + return false + } + if !strings.Contains(ua, normalizedMarker) { + return false + } + } + return true +} + +// MatchAllowedClients 判断请求头是否命中 clientIDs 引用的任一预设签名。 +// 未知预设 ID 会被忽略;空列表恒不放行(默认拒绝)。 +func MatchAllowedClients(userAgent, originator string, clientIDs []string) bool { + for _, id := range clientIDs { + entry, ok := allowedClientRegistry[normalizeCodexClientHeader(id)] + if !ok { + continue + } + if IsAllowedClientMatch(userAgent, originator, entry) { + return true + } + } + return false +} diff --git a/backend/internal/pkg/openai/allowed_client_test.go b/backend/internal/pkg/openai/allowed_client_test.go new file mode 100644 index 00000000..c42aa4d5 --- /dev/null +++ b/backend/internal/pkg/openai/allowed_client_test.go @@ -0,0 +1,95 @@ +package openai + +import "testing" + +// 真实的 Claude Code codex 插件请求头:originator 与 UA 前缀同源于 clientInfo.name="Claude Code"。 +const ( + testClaudeCodeOriginator = "Claude Code" + testClaudeCodeUserAgent = "Claude Code/0.5.0 (Macos 15.5; arm64) iTerm2.app (Claude Code; 1.0.4)" +) + +func TestIsAllowedClientMatch(t *testing.T) { + entry := AllowedClientEntry{Originator: "Claude Code", UAContains: []string{"Claude Code/"}} + + tests := []struct { + name string + ua string + originator string + want bool + }{ + {name: "真实签名命中", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, want: true}, + {name: "大小写不敏感", ua: "claude code/0.5.0 (macos)", originator: "claude code", want: true}, + {name: "originator 两侧空白被裁剪", ua: testClaudeCodeUserAgent, originator: " Claude Code ", want: true}, + {name: "originator 非精确(带后缀)不命中", ua: testClaudeCodeUserAgent, originator: "Claude Code Extra", want: false}, + {name: "originator 为空不命中", ua: testClaudeCodeUserAgent, originator: "", want: false}, + {name: "originator 是官方 codex 不命中", ua: testClaudeCodeUserAgent, originator: "codex_cli_rs", want: false}, + {name: "UA 缺少 Claude Code/ 标记不命中", ua: "curl/8.0", originator: testClaudeCodeOriginator, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsAllowedClientMatch(tt.ua, tt.originator, entry); got != tt.want { + t.Fatalf("IsAllowedClientMatch(%q, %q) = %v, want %v", tt.ua, tt.originator, got, tt.want) + } + }) + } +} + +func TestIsAllowedClientMatch_EmptyOriginatorEntryNeverMatches(t *testing.T) { + // registry 条目若没有配置 Originator,绝不放行,避免成为宽松后门。 + entry := AllowedClientEntry{Originator: "", UAContains: []string{"Claude Code/"}} + if IsAllowedClientMatch(testClaudeCodeUserAgent, "", entry) { + t.Fatal("空 Originator 的条目不应匹配任何请求") + } +} + +func TestIsAllowedClientMatch_EmptyUAContainsNeverMatches(t *testing.T) { + // 预设必须声明 UA 特征,否则退化为仅凭可伪造的 originator 单因子匹配,绝不放行。 + entry := AllowedClientEntry{Originator: "Claude Code", UAContains: nil} + if IsAllowedClientMatch(testClaudeCodeUserAgent, testClaudeCodeOriginator, entry) { + t.Fatal("未声明 UA 特征的预设不应匹配,避免退化为单因子 originator 匹配") + } +} + +func TestIsAllowedClientMatch_WhitespaceUAMarkerNeverMatches(t *testing.T) { + // 全空白 marker 归一化后为空,若被跳过则退化为仅 originator 单因子; + // 任何空白 marker 视为非法预设配置,必须安全失败。 + entry := AllowedClientEntry{Originator: "Claude Code", UAContains: []string{" "}} + if IsAllowedClientMatch(testClaudeCodeUserAgent, testClaudeCodeOriginator, entry) { + t.Fatal("UAContains 含全空白 marker 不应匹配,避免退化为单因子 originator 匹配") + } +} + +func TestIsAllowedClientMatch_MixedEmptyUAMarkerNeverMatches(t *testing.T) { + // 即便 UAContains 含一个真实 marker,只要其中混入任何空白 marker 也视为非法配置; + // 防止维护者只为对齐凑数而插入空字符串。 + entry := AllowedClientEntry{Originator: "Claude Code", UAContains: []string{"", "Claude Code/"}} + if IsAllowedClientMatch(testClaudeCodeUserAgent, testClaudeCodeOriginator, entry) { + t.Fatal("UAContains 混入空白 marker 不应匹配") + } +} + +func TestMatchAllowedClients(t *testing.T) { + tests := []struct { + name string + ua string + originator string + clientIDs []string + want bool + }{ + {name: "claude_code 预设命中真实签名", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{AllowedClientClaudeCode}, want: true}, + {name: "claude_code 预设 + 伪造 originator 不命中", ua: testClaudeCodeUserAgent, originator: "my_client", clientIDs: []string{AllowedClientClaudeCode}, want: false}, + {name: "空列表不放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: nil, want: false}, + {name: "未知预设 ID 不放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{"unknown_client"}, want: false}, + {name: "ID 大小写/空白容错", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{" Claude_Code "}, want: true}, + {name: "多预设任一命中即放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{"unknown_client", AllowedClientClaudeCode}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MatchAllowedClients(tt.ua, tt.originator, tt.clientIDs); got != tt.want { + t.Fatalf("MatchAllowedClients(%q, %q, %v) = %v, want %v", tt.ua, tt.originator, tt.clientIDs, got, tt.want) + } + }) + } +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 662daed1..9eea0924 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -843,6 +843,7 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": true, "openai_codex_user_agent": "", + "openai_allow_claude_code_codex_plugin": false, "openai_fast_policy_settings": { "rules": [] }, @@ -1079,6 +1080,7 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": false, "openai_codex_user_agent": "", + "openai_allow_claude_code_codex_plugin": false, "openai_fast_policy_settings": { "rules": [] }, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index d488aa75..f51f0325 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1442,6 +1442,38 @@ func (a *Account) IsCodexCLIOnlyEnabled() bool { return ok && enabled } +// GetCodexCLIOnlyAllowedClients 返回 codex_cli_only 之上额外放行的命名客户端预设 ID 列表。 +// 仅 OpenAI OAuth 账号生效;缺失或类型不符时返回空。预设 ID 的具体匹配规则由 +// openai 包的 registry 固化,配置只能引用预设键、不能自定义规则。 +func (a *Account) GetCodexCLIOnlyAllowedClients() []string { + if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil { + return nil + } + raw, ok := a.Extra["codex_cli_only_allowed_clients"] + if !ok || raw == nil { + return nil + } + switch v := raw.(type) { + case []string: + result := make([]string, 0, len(v)) + for _, s := range v { + if strings.TrimSpace(s) != "" { + result = append(result, s) + } + } + return result + case []any: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + result = append(result, s) + } + } + return result + } + return nil +} + // WindowCostSchedulability 窗口费用调度状态 type WindowCostSchedulability int diff --git a/backend/internal/service/account_codex_cli_only_allowed_clients_test.go b/backend/internal/service/account_codex_cli_only_allowed_clients_test.go new file mode 100644 index 00000000..c835ea27 --- /dev/null +++ b/backend/internal/service/account_codex_cli_only_allowed_clients_test.go @@ -0,0 +1,68 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccount_GetCodexCLIOnlyAllowedClients(t *testing.T) { + t.Run("OAuth 账号读取 []any 字符串列表", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code"}}, + } + require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients()) + }) + + t.Run("OAuth 账号读取 []string 列表", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only_allowed_clients": []string{"claude_code"}}, + } + require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients()) + }) + + t.Run("[]string 跳过空白元素", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only_allowed_clients": []string{"claude_code", "", " "}}, + } + require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients()) + }) + + t.Run("跳过非字符串与空白元素", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code", 123, "", " "}}, + } + require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients()) + }) + + t.Run("非 OAuth 账号返回空", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code"}}, + } + require.Empty(t, account.GetCodexCLIOnlyAllowedClients()) + }) + + t.Run("Extra 为空返回空", func(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + require.Empty(t, account.GetCodexCLIOnlyAllowedClients()) + }) + + t.Run("字段缺失返回空", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{}, + } + require.Empty(t, account.GetCodexCLIOnlyAllowedClients()) + }) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 59c34eaa..b6441238 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -431,6 +431,9 @@ const ( // 当客户端 UA 被识别为浏览器(Chrome/Firefox/Safari/Edge 等)时,转发给 OpenAI 上游前会替换为此值, // 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。 SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent" + // SettingKeyOpenAIAllowClaudeCodeCodexPlugin 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认 false)。 + // 仅在账号 codex_cli_only 开启时生效;开启后无需逐账号配置 codex_cli_only_allowed_clients。 + SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" // 余额不足提醒 SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关 diff --git a/backend/internal/service/openai_client_restriction_detector.go b/backend/internal/service/openai_client_restriction_detector.go index d1784e11..8589737a 100644 --- a/backend/internal/service/openai_client_restriction_detector.go +++ b/backend/internal/service/openai_client_restriction_detector.go @@ -13,6 +13,10 @@ const ( CodexClientRestrictionReasonMatchedUA = "official_client_user_agent_matched" // CodexClientRestrictionReasonMatchedOriginator 表示请求命中官方客户端 originator 白名单。 CodexClientRestrictionReasonMatchedOriginator = "official_client_originator_matched" + // CodexClientRestrictionReasonMatchedAllowedClient 表示请求命中账号级额外放行的命名客户端预设。 + CodexClientRestrictionReasonMatchedAllowedClient = "allowed_client_matched" + // CodexClientRestrictionReasonMatchedGlobalAllowedClient 表示请求命中全局额外放行的命名客户端预设。 + CodexClientRestrictionReasonMatchedGlobalAllowedClient = "global_allowed_client_matched" // CodexClientRestrictionReasonNotMatchedUA 表示请求未命中官方客户端 UA 白名单。 CodexClientRestrictionReasonNotMatchedUA = "official_client_user_agent_not_matched" // CodexClientRestrictionReasonForceCodexCLI 表示通过 ForceCodexCLI 配置兜底放行。 @@ -28,7 +32,7 @@ type CodexClientRestrictionDetectionResult struct { // CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。 type CodexClientRestrictionDetector interface { - Detect(c *gin.Context, account *Account) CodexClientRestrictionDetectionResult + Detect(c *gin.Context, account *Account, globalAllowedClients []string) CodexClientRestrictionDetectionResult } // OpenAICodexClientRestrictionDetector 为 OpenAI OAuth codex_cli_only 的默认实现。 @@ -40,7 +44,7 @@ func NewOpenAICodexClientRestrictionDetector(cfg *config.Config) *OpenAICodexCli return &OpenAICodexClientRestrictionDetector{cfg: cfg} } -func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account) CodexClientRestrictionDetectionResult { +func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account, globalAllowedClients []string) CodexClientRestrictionDetectionResult { if account == nil || !account.IsCodexCLIOnlyEnabled() { return CodexClientRestrictionDetectionResult{ Enabled: false, @@ -78,6 +82,26 @@ func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *A } } + // 官方客户端白名单未命中时,先尝试账号级额外放行的命名客户端预设(如 Claude Code codex 插件)。 + if allowed := account.GetCodexCLIOnlyAllowedClients(); len(allowed) > 0 && + openai.MatchAllowedClients(userAgent, originator, allowed) { + return CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: true, + Reason: CodexClientRestrictionReasonMatchedAllowedClient, + } + } + + // 再尝试由更高作用域(全局设置)注入的额外放行客户端列表。 + if len(globalAllowedClients) > 0 && + openai.MatchAllowedClients(userAgent, originator, globalAllowedClients) { + return CodexClientRestrictionDetectionResult{ + Enabled: true, + Matched: true, + Reason: CodexClientRestrictionReasonMatchedGlobalAllowedClient, + } + } + return CodexClientRestrictionDetectionResult{ Enabled: true, Matched: false, diff --git a/backend/internal/service/openai_client_restriction_detector_test.go b/backend/internal/service/openai_client_restriction_detector_test.go index 984b4ff6..fc115128 100644 --- a/backend/internal/service/openai_client_restriction_detector_test.go +++ b/backend/internal/service/openai_client_restriction_detector_test.go @@ -30,7 +30,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { detector := NewOpenAICodexClientRestrictionDetector(nil) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{}} - result := detector.Detect(newCodexDetectorTestContext("curl/8.0", ""), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", ""), account, nil) require.False(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason) @@ -44,7 +44,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0", ""), account) + result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0", ""), account, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -58,7 +58,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0", ""), account) + result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0", ""), account, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -72,7 +72,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0", ""), account) + result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0", ""), account, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -86,7 +86,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "codex_chatgpt_desktop"), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "codex_chatgpt_desktop"), account, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedOriginator, result.Reason) @@ -100,7 +100,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, nil) require.True(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) @@ -116,9 +116,146 @@ func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) { Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason) }) } + +func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T) { + gin.SetMode(gin.TestMode) + + const ( + claudeCodeUA = "Claude Code/0.5.0 (Macos 15.5; arm64) iTerm2.app (Claude Code; 1.0.4)" + claudeCodeOriginator = "Claude Code" + ) + + t.Run("配置 claude_code 白名单且命中真实签名时放行", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_cli_only": true, + "codex_cli_only_allowed_clients": []any{"claude_code"}, + }, + } + + result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, nil) + require.True(t, result.Enabled) + require.True(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedAllowedClient, result.Reason) + }) + + t.Run("配置白名单但伪造 originator 仍拒绝", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_cli_only": true, + "codex_cli_only_allowed_clients": []any{"claude_code"}, + }, + } + + result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, "my_client"), account, nil) + require.True(t, result.Enabled) + require.False(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) + }) + + t.Run("未配置白名单时 Claude Code 签名仍拒绝", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true}, + } + + result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, nil) + require.True(t, result.Enabled) + require.False(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) + }) + + t.Run("未开启 codex_cli_only 时白名单不参与,直接绕过", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code"}}, + } + + result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, nil) + require.False(t, result.Enabled) + require.False(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason) + }) + + t.Run("全局列表含 claude_code + 命中签名 → 放行(global)", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true}, + } + result := detector.Detect( + newCodexDetectorTestContext("Claude Code/0.5.0 (Macos 15.5; arm64) iTerm2.app (Claude Code; 1.0.4)", "Claude Code"), + account, + []string{"claude_code"}, + ) + require.True(t, result.Enabled) + require.True(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedGlobalAllowedClient, result.Reason) + }) + + t.Run("全局列表含 claude_code + 非签名 → 403", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true}, + } + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, []string{"claude_code"}) + require.True(t, result.Enabled) + require.False(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) + }) + + t.Run("全局列表为空 + 账号未配 → 403", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true}, + } + result := detector.Detect( + newCodexDetectorTestContext("Claude Code/0.5.0 (Macos) (Claude Code; 1.0.4)", "Claude Code"), + account, + nil, + ) + require.True(t, result.Enabled) + require.False(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) + }) + + t.Run("账号白名单优先于全局列表(reason=account)", func(t *testing.T) { + detector := NewOpenAICodexClientRestrictionDetector(nil) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_cli_only": true, + "codex_cli_only_allowed_clients": []any{"claude_code"}, + }, + } + result := detector.Detect( + newCodexDetectorTestContext("Claude Code/0.5.0 (Macos) (Claude Code; 1.0.4)", "Claude Code"), + account, + []string{"claude_code"}, + ) + require.True(t, result.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedAllowedClient, result.Reason) + }) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index f93cc221..997423b7 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -901,7 +901,17 @@ func SnapshotOpenAICompatibilityFallbackMetrics() OpenAICompatibilityFallbackMet } func (s *OpenAIGatewayService) detectCodexClientRestriction(c *gin.Context, account *Account) CodexClientRestrictionDetectionResult { - return s.getCodexClientRestrictionDetector().Detect(c, account) + var globalAllowedClients []string + if account != nil && account.IsCodexCLIOnlyEnabled() && s != nil && s.settingService != nil { + ctx := context.Background() + if c != nil && c.Request != nil { + ctx = c.Request.Context() + } + if s.settingService.IsOpenAIAllowClaudeCodeCodexPluginEnabled(ctx) { + globalAllowedClients = []string{openai.AllowedClientClaudeCode} + } + } + return s.getCodexClientRestrictionDetector().Detect(c, account, globalAllowedClients) } func getAPIKeyIDFromContext(c *gin.Context) int64 { @@ -959,6 +969,7 @@ func logCodexCLIOnlyDetection(ctx context.Context, c *gin.Context, account *Acco } log := logger.FromContext(ctx).With(fields...) if result.Matched { + log.Info("OpenAI codex_cli_only 放行请求") return } log.Warn("OpenAI codex_cli_only 拒绝非官方客户端请求") diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 17a874ea..10d58654 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -18,7 +18,7 @@ type stubCodexRestrictionDetector struct { result CodexClientRestrictionDetectionResult } -func (s *stubCodexRestrictionDetector) Detect(_ *gin.Context, _ *Account) CodexClientRestrictionDetectionResult { +func (s *stubCodexRestrictionDetector) Detect(_ *gin.Context, _ *Account, _ []string) CodexClientRestrictionDetectionResult { return s.result } @@ -52,7 +52,7 @@ func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) { c.Request.Header.Set("User-Agent", "curl/8.0") account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} - result := got.Detect(c, account) + result := got.Detect(c, account, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index e6f0f2bc..08c0d045 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -141,6 +141,17 @@ const openAICodexUserAgentCacheTTL = 60 * time.Second const openAICodexUserAgentErrorTTL = 5 * time.Second const openAICodexUserAgentDBTimeout = 5 * time.Second +// cachedOpenAIAllowCodexPlugin Codex 插件放行开关缓存(进程内缓存,60s TTL)。 +// IsOpenAIAllowClaudeCodeCodexPluginEnabled 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。 +type cachedOpenAIAllowCodexPlugin struct { + value bool + expiresAt int64 // unix nano +} + +const openAIAllowCodexPluginCacheTTL = 60 * time.Second +const openAIAllowCodexPluginErrorTTL = 5 * time.Second +const openAIAllowCodexPluginDBTimeout = 5 * time.Second + // DefaultSubscriptionGroupReader validates group references used by default subscriptions. type DefaultSubscriptionGroupReader interface { GetByID(ctx context.Context, id int64) (*Group, error) @@ -152,17 +163,19 @@ type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[i // SettingService 系统设置服务 type SettingService struct { - settingRepo SettingRepository - defaultSubGroupReader DefaultSubscriptionGroupReader - proxyRepo ProxyRepository // for resolving websearch provider proxy URLs - cfg *config.Config - onUpdate func() // Callback when settings are updated (for cache invalidation) - version string // Application version - webSearchManagerBuilder WebSearchManagerBuilder - antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion - antigravityUAVersionSF singleflight.Group - openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent - openAICodexUASF singleflight.Group + settingRepo SettingRepository + defaultSubGroupReader DefaultSubscriptionGroupReader + proxyRepo ProxyRepository // for resolving websearch provider proxy URLs + cfg *config.Config + onUpdate func() // Callback when settings are updated (for cache invalidation) + version string // Application version + webSearchManagerBuilder WebSearchManagerBuilder + antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion + antigravityUAVersionSF singleflight.Group + openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent + openAICodexUASF singleflight.Group + openAIAllowCodexPluginCache atomic.Value // *cachedOpenAIAllowCodexPlugin + openAIAllowCodexPluginSF singleflight.Group } // DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限) @@ -1015,6 +1028,54 @@ func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string { return fallback } +// IsOpenAIAllowClaudeCodeCodexPluginEnabled 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认关闭)。 +// 仅在调用方已确认账号 codex_cli_only 开启时读取,避免对非受限账号产生无谓查询。 +// 使用进程内 atomic.Value 缓存(60s TTL),避免在每个网关请求热路径上访问 DB。 +func (s *SettingService) IsOpenAIAllowClaudeCodeCodexPluginEnabled(ctx context.Context) bool { + if cached, ok := s.openAIAllowCodexPluginCache.Load().(*cachedOpenAIAllowCodexPlugin); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value + } + } + result, _, _ := s.openAIAllowCodexPluginSF.Do("openai_allow_codex_plugin_enabled", func() (any, error) { + if cached, ok := s.openAIAllowCodexPluginCache.Load().(*cachedOpenAIAllowCodexPlugin); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.value, nil + } + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAllowCodexPluginDBTimeout) + defer cancel() + value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + // 设置不存在 → 默认关闭,正常 TTL 缓存 + s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{ + value: false, + expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(), + }) + return false, nil + } + slog.Warn("failed to get openai_allow_claude_code_codex_plugin setting", "error", err) + // DB 错误 → 安全默认关闭,短 TTL 快速重试 + s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{ + value: false, + expiresAt: time.Now().Add(openAIAllowCodexPluginErrorTTL).UnixNano(), + }) + return false, nil + } + enabled := value == "true" + s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{ + value: enabled, + expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(), + }) + return enabled, nil + }) + if val, ok := result.(bool); ok { + return val + } + return false +} + // SetOnUpdateCallback sets a callback function to be called when settings are updated // This is used for cache invalidation (e.g., HTML cache in frontend server) func (s *SettingService) SetOnUpdateCallback(callback func()) { @@ -1816,6 +1877,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl) updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion) updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent) + updates[SettingKeyOpenAIAllowClaudeCodeCodexPlugin] = strconv.FormatBool(settings.OpenAIAllowClaudeCodeCodexPlugin) updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) @@ -1968,6 +2030,11 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { if s.cfg != nil { s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP) } + s.openAIAllowCodexPluginSF.Forget("openai_allow_codex_plugin_enabled") + s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{ + value: settings.OpenAIAllowClaudeCodeCodexPlugin, + expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(), + }) if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } @@ -3233,6 +3300,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion]) result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent]) + result.OpenAIAllowClaudeCodeCodexPlugin = settings[SettingKeyOpenAIAllowClaudeCodeCodexPlugin] == "true" // Web search emulation: quick enabled check from the JSON config if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { diff --git a/backend/internal/service/setting_service_openai_allow_claude_code_test.go b/backend/internal/service/setting_service_openai_allow_claude_code_test.go new file mode 100644 index 00000000..22059f07 --- /dev/null +++ b/backend/internal/service/setting_service_openai_allow_claude_code_test.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type allowClaudeCodeSettingRepoStub struct{ values map[string]string } + +func (s *allowClaudeCodeSettingRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unused") +} +func (s *allowClaudeCodeSettingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + if v, ok := s.values[key]; ok { + return v, nil + } + return "", ErrSettingNotFound +} +func (s *allowClaudeCodeSettingRepoStub) Set(ctx context.Context, key, value string) error { + panic("unused") +} +func (s *allowClaudeCodeSettingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + panic("unused") +} +func (s *allowClaudeCodeSettingRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unused") +} +func (s *allowClaudeCodeSettingRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + panic("unused") +} +func (s *allowClaudeCodeSettingRepoStub) Delete(ctx context.Context, key string) error { + panic("unused") +} + +func TestSettingService_IsOpenAIAllowClaudeCodeCodexPluginEnabled(t *testing.T) { + t.Run("默认关闭(设置缺失)", func(t *testing.T) { + svc := NewSettingService(&allowClaudeCodeSettingRepoStub{values: map[string]string{}}, &config.Config{}) + require.False(t, svc.IsOpenAIAllowClaudeCodeCodexPluginEnabled(context.Background())) + }) + t.Run("值为 true 时开启", func(t *testing.T) { + svc := NewSettingService(&allowClaudeCodeSettingRepoStub{values: map[string]string{ + SettingKeyOpenAIAllowClaudeCodeCodexPlugin: "true", + }}, &config.Config{}) + require.True(t, svc.IsOpenAIAllowClaudeCodeCodexPluginEnabled(context.Background())) + }) + t.Run("值非 true 时关闭", func(t *testing.T) { + svc := NewSettingService(&allowClaudeCodeSettingRepoStub{values: map[string]string{ + SettingKeyOpenAIAllowClaudeCodeCodexPlugin: "false", + }}, &config.Config{}) + require.False(t, svc.IsOpenAIAllowClaudeCodeCodexPluginEnabled(context.Background())) + }) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 3f961ab2..7b45ef1a 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -195,6 +195,7 @@ type SystemSettings struct { RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false) AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值 OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认 + OpenAIAllowClaudeCodeCodexPlugin bool // 全局开关:是否额外放行 Claude Code 的 Codex 插件(默认 false) // Web Search Emulation WebSearchEmulationEnabled bool // 是否启用 web search 模拟 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index d2b878cc..6d8e6cee 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -560,6 +560,7 @@ export interface SystemSettings { rewrite_message_cache_control: boolean; antigravity_user_agent_version: string; openai_codex_user_agent: string; + openai_allow_claude_code_codex_plugin: boolean; web_search_emulation_enabled?: boolean; // Payment configuration @@ -792,6 +793,7 @@ export interface UpdateSettingsRequest { rewrite_message_cache_control?: boolean; antigravity_user_agent_version?: string; openai_codex_user_agent?: string; + openai_allow_claude_code_codex_plugin?: boolean; // Payment configuration payment_enabled?: boolean; risk_control_enabled?: boolean; diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index c8d53220..6e71fe4b 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -742,6 +742,50 @@ + +
+ {{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }} +
+ ++ {{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }} +
++ {{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }} +
++ {{ t("admin.settings.gatewayForwarding.openaiAllowClaudeCodeCodexPluginDesc") }} +
+