From 819fda34d9341a80974bf2c808ed9039839fa3a7 Mon Sep 17 00:00:00 2001 From: DaydreamCoding <22166516+DaydreamCoding@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:38:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(codex-detect):=20codex=5Fcli=5Fonly=20?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=8A=A0=E5=9B=BA=20+=20=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E7=BB=9F=E4=B8=80=E4=BF=A1=E5=8F=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20+=20=E8=B4=A6=E5=8F=B7=E7=BA=A7=20app-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 codex_cli_only 客户端识别从「单一 strict 开关 + 固定 OR 头集合」重构为 可逐项管理的引擎指纹信号列表,加固整条判定链,并补齐账号级 app-server 控制、 对齐前端设置文案。 判定链(每步可短路): - 账号未开 codex_cli_only → 不限制;gateway.force_codex_cli → 旁路放行 - 全局黑名单命中(OR 宽 deny)→ 立即拒 - 身份候选:官方 UA(strict,仅前缀)/ 官方 originator(OR)/ 全局白名单(双因子 AND) / 全局 app-server 开关 OR 账号 app-server 开关;均不命中 → 拒 - 版本门(仅官方候选):UA 须可解析引擎版本,再校验 [min,max] 区间 - 引擎指纹 AND 硬门:按信号列表逐条勾选 AND、每条行内变体 OR;无 Required 信号 → 放行 引擎指纹信号列表(唯一真源) - 新增 openai.EngineFingerprintSignal 类型 + EvaluateEngineFingerprint 求值器 (勾选 AND / 行内变体 OR / 无勾选 → 放行) - CodexRestrictionPolicy 增 EngineFingerprintSignals;信号列表单一决定是否启用指纹门, 不再保留独立「要求引擎指纹」总开关(与「信号全不选」语义重复) - 新设置键 codex_cli_only_engine_fingerprint_signals(默认只勾 x-codex- 前缀); 旧 body 指纹开关幂等迁移并入信号列表;wire 接线 - 黑/白名单自由条目、命名预设、版本区间 全局设置管线 - gateway 缺 settingService(仅测试/误配可达)时指纹门回退默认种子信号、失败关闭, 不再因零值 policy(nil 信号)失败开放 账号级 Codex app-server(替换已失效的 ClaudeCode 放行机制) - account.IsCodexCLIOnlyAppServerAllowed() 读 extra.codex_cli_only_allow_app_server, 仅在 codex_cli_only 开启时生效;候选身份门「全局 OR 账号」,与旧系统双层控制对齐 - 移除已无入口的 claude_code 预设机制(allowedClientRegistry / MatchAllowedClients / 账号 GetCodexCLIOnlyAllowedClients / reason);白名单 AllowedClientEntry / IsAllowedClientMatch 保留 门加固(反伪 + 写入校验) - 官方 UA 访问门改 strict:IsCodexOfficialClientRequestStrict 仅前缀匹配,收窄「浏览器前缀 + 中段 codex token」伪造面(strict 仍保留 Codex 家族前缀与 UA 尾部兜底,故对「任意前缀 + 官方尾部 (name;ver)」仍放行——与 UA 可伪造、真正反伪靠引擎指纹门的设计一致) - 官方客户端识别扩展:新增 codex-tui/、codex_vscode_copilot/ 前缀 + UA 尾部 (name;ver) 兜底 (恢复 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 的真实 client,如 cccc→codex-tui),originator 改 精确集。该识别经 IsCodexOfficialClientByHeaders 被 passthrough 复用,故透传的官方判定一并 修正(codex-tui 等不再被误改写 UA)——非「行为不变」,属有意修正 - 白名单写入校验 ValidateCodexWhitelistEntriesJSON + AllowedClientEntry.IsWhitelistable: 双因子 AND 条目须可命中(非空 originator + 非空 ua_contains),拒绝写入会静默失效的死规则; 黑名单(OR 宽 deny,允许 originator-only)不受约束 管理端 / 前端 - handler / DTO / settings_view / 契约测试;gateway 接入判定链 - 信号列表编辑器(替换 body 开关)、api 类型、SettingsView;无勾选给常驻警告 - Create/Edit/Bulk 三弹窗「Codex Only」下新增 app-server 开关(OR 合并全局) - 文案:UA/Originator → User-Agent/Originator;黑/白名单重命名为 User-Agent/Originator 黑/白名单; 「允许 App Server 第三方客户端」→「Codex app-server」+ 简介示例;i18n zh/en 同步 - 移除死代码 HasCodex*Fingerprint helper 测试:引擎指纹求值器 / 账号 app-server(OR 语义)/ detector(含 N1 strict、失败关闭)/ 白名单写入校验 / BulkEdit spec 等;后端 build + service/openai/admin 单测全绿,前端 vue-tsc + vitest 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/handler/admin/setting_handler.go | 85 +++- .../setting_handler_codex_whitelist_test.go | 58 +++ backend/internal/handler/dto/settings.go | 9 +- backend/internal/pkg/openai/allowed_client.go | 89 +++-- .../openai/allowed_client_freeform_test.go | 26 ++ .../pkg/openai/allowed_client_skip_test.go | 29 ++ .../pkg/openai/allowed_client_test.go | 25 -- .../allowed_client_whitelistable_test.go | 26 ++ .../pkg/openai/engine_fingerprint_signal.go | 130 ++++++ .../openai/engine_fingerprint_signal_test.go | 99 +++++ backend/internal/pkg/openai/request.go | 157 +++++++- backend/internal/pkg/openai/request_test.go | 75 ++++ .../pkg/openai/request_version_test.go | 32 ++ backend/internal/server/api_contract_test.go | 14 +- backend/internal/service/account.go | 37 +- ...unt_codex_cli_only_allowed_clients_test.go | 68 ---- .../account_codex_cli_only_app_server_test.go | 45 +++ backend/internal/service/domain_constants.go | 19 +- .../openai_client_restriction_detector.go | 135 ++++--- ...ent_restriction_detector_hardening_test.go | 136 +++++++ ...openai_client_restriction_detector_test.go | 233 +++++++---- .../openai_gateway_chat_completions.go | 2 +- .../service/openai_gateway_service.go | 14 +- ...nai_gateway_service_codex_cli_only_test.go | 4 +- .../service/openai_oauth_passthrough_test.go | 7 +- backend/internal/service/setting_service.go | 334 +++++++++++++--- .../setting_service_codex_policy_test.go | 204 ++++++++++ .../setting_service_codex_validate_test.go | 42 ++ ...g_service_openai_allow_claude_code_test.go | 55 --- backend/internal/service/settings_view.go | 7 +- backend/internal/service/wire.go | 6 + frontend/src/api/admin/settings.ts | 16 +- .../account/BulkEditAccountModal.vue | 48 ++- .../components/account/CreateAccountModal.vue | 25 +- .../components/account/EditAccountModal.vue | 28 +- .../__tests__/BulkEditAccountModal.spec.ts | 27 +- frontend/src/i18n/locales/en.ts | 44 ++- frontend/src/i18n/locales/zh.ts | 42 +- frontend/src/views/admin/SettingsView.vue | 374 +++++++++++++++++- .../__tests__/codexFingerprintSignals.spec.ts | 31 ++ .../views/admin/codexFingerprintSignals.ts | 58 +++ 41 files changed, 2405 insertions(+), 490 deletions(-) create mode 100644 backend/internal/handler/admin/setting_handler_codex_whitelist_test.go create mode 100644 backend/internal/pkg/openai/allowed_client_freeform_test.go create mode 100644 backend/internal/pkg/openai/allowed_client_skip_test.go create mode 100644 backend/internal/pkg/openai/allowed_client_whitelistable_test.go create mode 100644 backend/internal/pkg/openai/engine_fingerprint_signal.go create mode 100644 backend/internal/pkg/openai/engine_fingerprint_signal_test.go create mode 100644 backend/internal/pkg/openai/request_version_test.go delete mode 100644 backend/internal/service/account_codex_cli_only_allowed_clients_test.go create mode 100644 backend/internal/service/account_codex_cli_only_app_server_test.go create mode 100644 backend/internal/service/openai_client_restriction_detector_hardening_test.go create mode 100644 backend/internal/service/setting_service_codex_policy_test.go create mode 100644 backend/internal/service/setting_service_codex_validate_test.go delete mode 100644 backend/internal/service/setting_service_openai_allow_claude_code_test.go create mode 100644 frontend/src/views/admin/__tests__/codexFingerprintSignals.spec.ts create mode 100644 frontend/src/views/admin/codexFingerprintSignals.ts diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 6bad41018c..4f4807130e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -261,7 +261,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RewriteMessageCacheControl: settings.RewriteMessageCacheControl, AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, OpenAICodexUserAgent: settings.OpenAICodexUserAgent, - OpenAIAllowClaudeCodeCodexPlugin: settings.OpenAIAllowClaudeCodeCodexPlugin, + MinCodexVersion: settings.MinCodexVersion, + MaxCodexVersion: settings.MaxCodexVersion, + CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, + CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist, + CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients, + CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals, WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, @@ -595,7 +600,14 @@ 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"` + + // codex_cli_only 加固(global-only) + MinCodexVersion string `json:"min_codex_version"` + MaxCodexVersion string `json:"max_codex_version"` + CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"` + CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"` + CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"` + CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"` // Payment visible method routing PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"` @@ -1466,6 +1478,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) + if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { + response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)") + return + } + if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) { + response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)") + return + } + if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 { + response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version") + return + } + // codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。 + // 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。 + if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil { + response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error()) + return + } + if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil { + response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error()) + return + } + if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil { + response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error()) + return + } + // 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号 if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" { if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 { @@ -1703,12 +1743,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexUserAgent }(), - OpenAIAllowClaudeCodeCodexPlugin: func() bool { - if req.OpenAIAllowClaudeCodeCodexPlugin != nil { - return *req.OpenAIAllowClaudeCodeCodexPlugin + MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), + MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), + CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), + CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist), + CodexCLIOnlyAllowAppServerClients: func() bool { + if req.CodexCLIOnlyAllowAppServerClients != nil { + return *req.CodexCLIOnlyAllowAppServerClients } - return previousSettings.OpenAIAllowClaudeCodeCodexPlugin + return previousSettings.CodexCLIOnlyAllowAppServerClients }(), + CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals), PaymentVisibleMethodAlipaySource: func() string { if req.PaymentVisibleMethodAlipaySource != nil { return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource) @@ -2100,7 +2145,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, - OpenAIAllowClaudeCodeCodexPlugin: updatedSettings.OpenAIAllowClaudeCodeCodexPlugin, + MinCodexVersion: updatedSettings.MinCodexVersion, + MaxCodexVersion: updatedSettings.MaxCodexVersion, + CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, + CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist, + CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients, + CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals, PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, @@ -2528,6 +2578,24 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion { changed = append(changed, "max_claude_code_version") } + if before.MinCodexVersion != after.MinCodexVersion { + changed = append(changed, "min_codex_version") + } + if before.MaxCodexVersion != after.MaxCodexVersion { + changed = append(changed, "max_codex_version") + } + if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients { + changed = append(changed, "codex_cli_only_allow_app_server_clients") + } + if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals { + changed = append(changed, "codex_cli_only_engine_fingerprint_signals") + } + if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist { + changed = append(changed, "codex_cli_only_blacklist") + } + if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist { + changed = append(changed, "codex_cli_only_whitelist") + } if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling { changed = append(changed, "allow_ungrouped_key_scheduling") } @@ -2582,9 +2650,6 @@ 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/admin/setting_handler_codex_whitelist_test.go b/backend/internal/handler/admin/setting_handler_codex_whitelist_test.go new file mode 100644 index 0000000000..a1e9e80377 --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_codex_whitelist_test.go @@ -0,0 +1,58 @@ +//go:build unit + +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// updateSettingsCodexStatus PUT /settings 仅带给定字段,返回 HTTP 状态码(轻量 stub repo,无 DB)。 +func updateSettingsCodexStatus(t *testing.T, body map[string]any) int { + t.Helper() + gin.SetMode(gin.TestMode) + repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyPromoCodeEnabled: "true"}} + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) + + raw, err := json.Marshal(body) + require.NoError(t, err) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(raw)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + return rec.Code +} + +// 白名单是双因子 AND:originator-only 条目在运行时永不命中(静默失效)。 +// handler 应路由到 ValidateCodexWhitelistEntriesJSON,在写入时即拒(400)。 +func TestUpdateSettings_CodexWhitelistRejectsUnmatchable(t *testing.T) { + code := updateSettingsCodexStatus(t, map[string]any{ + "codex_cli_only_whitelist": `[{"originator":"opencode"}]`, + }) + require.Equal(t, http.StatusBadRequest, code, "白名单 originator-only 应被拒(静默失效防护)") +} + +func TestUpdateSettings_CodexWhitelistAcceptsMatchable(t *testing.T) { + code := updateSettingsCodexStatus(t, map[string]any{ + "codex_cli_only_whitelist": `[{"originator":"opencode","ua_contains":["opencode/"]}]`, + }) + require.Equal(t, http.StatusOK, code, "可命中白名单条目应通过") +} + +// 黑名单是 OR 宽 deny:允许 originator-only。非对称——不受白名单收紧影响。 +func TestUpdateSettings_CodexBlacklistAllowsOriginatorOnly(t *testing.T) { + code := updateSettingsCodexStatus(t, map[string]any{ + "codex_cli_only_blacklist": `[{"originator":"evil"}]`, + }) + require.Equal(t, http.StatusOK, code, "黑名单 originator-only 应允许(非对称)") +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 604428ef28..e55e06b59d 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -188,7 +188,14 @@ 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"` + + // codex_cli_only 加固 + MinCodexVersion string `json:"min_codex_version"` + MaxCodexVersion string `json:"max_codex_version"` + CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"` + CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"` + CodexCLIOnlyAllowAppServerClients bool `json:"codex_cli_only_allow_app_server_clients"` + CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"` // 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 index d4ca14ee65..092264956c 100644 --- a/backend/internal/pkg/openai/allowed_client.go +++ b/backend/internal/pkg/openai/allowed_client.go @@ -2,34 +2,35 @@ 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 单因子放行。 +// SkipEngineFingerprint 仅对白名单条目有意义:命中此条则跳过引擎指纹门(管理员显式承担 +// "纯 UA+originator、无引擎兜底"的后门风险,默认 false)。黑名单忽略此字段。 type AllowedClientEntry struct { - Originator string - UAContains []string + Originator string `json:"originator"` + UAContains []string `json:"ua_contains"` + SkipEngineFingerprint bool `json:"skip_engine_fingerprint"` } -// 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/"}, - }, +// IsWhitelistable 报告该条目作为白名单条目是否「有可能命中」——镜像 IsAllowedClientMatch 的结构性 +// 前置:originator 非空、ua_contains 至少一项、且无任何空白 marker(空白 marker 会让整条永不命中)。 +// 仅供管理端写入校验,避免存入静默失效的白名单规则。黑名单(OR 宽 deny,允许 originator-only)不受此约束。 +func (e AllowedClientEntry) IsWhitelistable() bool { + if normalizeCodexClientHeader(e.Originator) == "" { + return false + } + if len(e.UAContains) == 0 { + return false + } + for _, marker := range e.UAContains { + if normalizeCodexClientHeader(marker) == "" { + return false + } + } + return true } // IsAllowedClientMatch 判断请求头是否命中给定的额外客户端签名。 @@ -62,15 +63,47 @@ func IsAllowedClientMatch(userAgent, originator string, entry AllowedClientEntry 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 +// MatchClientEntry 同 MatchClientEntries(双因子 AND,复用 IsAllowedClientMatch),但回传命中的 +// 那条条目,供调用方读取 SkipEngineFingerprint 等条目级配置。未命中返回零值 + false。 +func MatchClientEntry(userAgent, originator string, entries []AllowedClientEntry) (AllowedClientEntry, bool) { + for _, e := range entries { + if IsAllowedClientMatch(userAgent, originator, e) { + return e, true } - if IsAllowedClientMatch(userAgent, originator, entry) { + } + return AllowedClientEntry{}, false +} + +// MatchClientEntries 判断请求头是否命中任一白名单自由条目(双因子 AND)。薄封装 MatchClientEntry。 +// 用于 codex_cli_only 全局白名单:放行官方集未覆盖的 app-server 新 client。 +func MatchClientEntries(userAgent, originator string, entries []AllowedClientEntry) bool { + _, ok := MatchClientEntry(userAgent, originator, entries) + return ok +} + +// IsDeniedClientMatch 黑名单单条 OR 语义:已声明字段中任一命中即 deny。 +// originator 精确等值命中,或任一非空 ua_contains marker 出现在 UA 中。 +// 全空字段(originator 与 ua_contains 均空)→ 不 deny(安全忽略)。 +// 与白名单 AND 非对称:deny 应宽(挡可疑),allow 应严(防伪造)。 +func IsDeniedClientMatch(userAgent, originator string, entry AllowedClientEntry) bool { + if want := normalizeCodexClientHeader(entry.Originator); want != "" { + if normalizeCodexClientHeader(originator) == want { + return true + } + } + ua := normalizeCodexClientHeader(userAgent) + for _, marker := range entry.UAContains { + if m := normalizeCodexClientHeader(marker); m != "" && strings.Contains(ua, m) { + return true + } + } + return false +} + +// MatchDenyEntries 判断请求头是否命中任一黑名单条目(OR)。 +func MatchDenyEntries(userAgent, originator string, entries []AllowedClientEntry) bool { + for _, e := range entries { + if IsDeniedClientMatch(userAgent, originator, e) { return true } } diff --git a/backend/internal/pkg/openai/allowed_client_freeform_test.go b/backend/internal/pkg/openai/allowed_client_freeform_test.go new file mode 100644 index 0000000000..db62cd6076 --- /dev/null +++ b/backend/internal/pkg/openai/allowed_client_freeform_test.go @@ -0,0 +1,26 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMatchClientEntries_WhitelistAND(t *testing.T) { + wl := []AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}}} + require.True(t, MatchClientEntries("opencode/1.2 (x)", "opencode", wl)) + require.False(t, MatchClientEntries("opencode/1.2 (x)", "other", wl), "originator 不符不放") + require.False(t, MatchClientEntries("curl/8", "opencode", wl), "UA marker 缺失不放") + require.False(t, MatchClientEntries("opencode/1.2", "opencode", []AllowedClientEntry{{Originator: "opencode"}}), "空 UAContains 安全失败") +} + +func TestDenyEntries_BlacklistOR(t *testing.T) { + bl := []AllowedClientEntry{ + {Originator: "evilbot"}, + {UAContains: []string{"badscan/"}}, + } + require.True(t, MatchDenyEntries("anything/1", "evilbot", bl), "originator 命中即拒") + require.True(t, MatchDenyEntries("badscan/9 (x)", "whatever", bl), "UA marker 命中即拒") + require.False(t, MatchDenyEntries("codex_cli_rs/0.141.0", "codex_cli_rs", bl), "都不命中不拒") + require.False(t, MatchDenyEntries("x", "y", []AllowedClientEntry{{}}), "全空条目安全忽略") +} diff --git a/backend/internal/pkg/openai/allowed_client_skip_test.go b/backend/internal/pkg/openai/allowed_client_skip_test.go new file mode 100644 index 0000000000..014a46d2de --- /dev/null +++ b/backend/internal/pkg/openai/allowed_client_skip_test.go @@ -0,0 +1,29 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMatchClientEntry_ReturnsHitEntry(t *testing.T) { + entries := []AllowedClientEntry{ + {Originator: "opencode", UAContains: []string{"opencode/"}, SkipEngineFingerprint: true}, + {Originator: "Claude Code", UAContains: []string{"Claude Code/"}}, + } + + e, ok := MatchClientEntry("opencode/1.0", "opencode", entries) + require.True(t, ok) + require.True(t, e.SkipEngineFingerprint) + + e2, ok2 := MatchClientEntry("Claude Code/1.0 (x) (Claude Code; 1)", "Claude Code", entries) + require.True(t, ok2) + require.False(t, e2.SkipEngineFingerprint) + + _, ok3 := MatchClientEntry("curl/8", "evil", entries) + require.False(t, ok3) + + // 薄封装保持兼容 + require.True(t, MatchClientEntries("opencode/1.0", "opencode", entries)) + require.False(t, MatchClientEntries("curl/8", "evil", entries)) +} diff --git a/backend/internal/pkg/openai/allowed_client_test.go b/backend/internal/pkg/openai/allowed_client_test.go index c42aa4d5cf..12c7d5c9c6 100644 --- a/backend/internal/pkg/openai/allowed_client_test.go +++ b/backend/internal/pkg/openai/allowed_client_test.go @@ -68,28 +68,3 @@ func TestIsAllowedClientMatch_MixedEmptyUAMarkerNeverMatches(t *testing.T) { 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/pkg/openai/allowed_client_whitelistable_test.go b/backend/internal/pkg/openai/allowed_client_whitelistable_test.go new file mode 100644 index 0000000000..7a88679469 --- /dev/null +++ b/backend/internal/pkg/openai/allowed_client_whitelistable_test.go @@ -0,0 +1,26 @@ +package openai + +import "testing" + +func TestAllowedClientEntry_IsWhitelistable(t *testing.T) { + cases := []struct { + name string + entry AllowedClientEntry + want bool + }{ + {name: "完整条目可白名单", entry: AllowedClientEntry{Originator: "opencode", UAContains: []string{"opencode/"}}, want: true}, + {name: "多个有效 marker 可白名单", entry: AllowedClientEntry{Originator: "x", UAContains: []string{"a/", "b/"}}, want: true}, + {name: "缺 originator → 不可(静默失效)", entry: AllowedClientEntry{UAContains: []string{"opencode/"}}, want: false}, + {name: "originator 全空白 → 不可", entry: AllowedClientEntry{Originator: " ", UAContains: []string{"opencode/"}}, want: false}, + {name: "缺 ua_contains → 不可(静默失效)", entry: AllowedClientEntry{Originator: "opencode"}, want: false}, + {name: "ua_contains 全空白 → 不可", entry: AllowedClientEntry{Originator: "opencode", UAContains: []string{"", " "}}, want: false}, + {name: "含一个空白 marker → 不可(空白会让整条 IsAllowedClientMatch 永不命中)", entry: AllowedClientEntry{Originator: "opencode", UAContains: []string{"opencode/", ""}}, want: false}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := tt.entry.IsWhitelistable(); got != tt.want { + t.Fatalf("IsWhitelistable(%+v) = %v, want %v", tt.entry, got, tt.want) + } + }) + } +} diff --git a/backend/internal/pkg/openai/engine_fingerprint_signal.go b/backend/internal/pkg/openai/engine_fingerprint_signal.go new file mode 100644 index 0000000000..6646f50b74 --- /dev/null +++ b/backend/internal/pkg/openai/engine_fingerprint_signal.go @@ -0,0 +1,130 @@ +package openai + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/tidwall/gjson" +) + +// EngineFingerprintSignal 描述引擎指纹统一列表的一条信号。 +// Required=true(勾选)= 该信号必须命中;多条 Required 之间 AND。 +// Match 为同一信号的等价写法/变体,行内 OR(命中任一即算该条满足)。 +type EngineFingerprintSignal struct { + Type string `json:"type"` // header_exact | header_prefix | body_path + Match []string `json:"match"` // 行内 OR 变体 + Required bool `json:"required"` // 勾选=true +} + +const ( + FingerprintSignalHeaderExact = "header_exact" + FingerprintSignalHeaderPrefix = "header_prefix" + FingerprintSignalBodyPath = "body_path" +) + +// DefaultEngineFingerprintSignals 默认种子:只勾 x-codex- 前缀,其余预填不勾。 +// 依据:实测真 codex(含旧版)约 98.8% 必带 x-codex-window-id 头。 +var DefaultEngineFingerprintSignals = []EngineFingerprintSignal{ + {Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: true}, + {Type: FingerprintSignalHeaderExact, Match: []string{"session-id", "session_id"}, Required: false}, + {Type: FingerprintSignalHeaderExact, Match: []string{"thread-id", "thread_id"}, Required: false}, + {Type: FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id", "client_metadata.x-codex-installation-id"}, Required: false}, +} + +// EvaluateEngineFingerprint 应用「勾选 AND / 行内变体 OR」规则。 +// 只有 Required=true 的条目参与;全部命中→true;任一缺失→false;无任何 Required→true。 +func EvaluateEngineFingerprint(h http.Header, body []byte, signals []EngineFingerprintSignal) bool { + for _, s := range signals { + if !s.Required { + continue + } + if !engineSignalMatches(h, body, s) { + return false + } + } + return true +} + +func engineSignalMatches(h http.Header, body []byte, s EngineFingerprintSignal) bool { + switch s.Type { + case FingerprintSignalHeaderExact: + for _, name := range s.Match { + if n := strings.TrimSpace(name); n != "" && h != nil && strings.TrimSpace(h.Get(n)) != "" { + return true + } + } + case FingerprintSignalHeaderPrefix: + if h == nil { + return false + } + for k := range h { + lk := strings.ToLower(k) + for _, p := range s.Match { + if np := strings.ToLower(strings.TrimSpace(p)); np != "" && strings.HasPrefix(lk, np) { + return true + } + } + } + case FingerprintSignalBodyPath: + if len(body) == 0 { + return false + } + for _, path := range s.Match { + if p := strings.TrimSpace(path); p != "" && gjson.GetBytes(body, p).Exists() { + return true + } + } + } + return false +} + +// ParseEngineFingerprintSignals 解析 JSON;空串→(nil,true);非法→(nil,false)。 +func ParseEngineFingerprintSignals(raw string) ([]EngineFingerprintSignal, bool) { + if strings.TrimSpace(raw) == "" { + return nil, true + } + var sigs []EngineFingerprintSignal + if json.Unmarshal([]byte(raw), &sigs) != nil { + return nil, false + } + return sigs, true +} + +// ValidateEngineFingerprintSignalsJSON 校验:空=合法;非空须为合法数组, +// 每条 type 合法且 match 至少一个非空项。供管理端写入校验复用。 +func ValidateEngineFingerprintSignalsJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var sigs []EngineFingerprintSignal + if err := json.Unmarshal([]byte(trimmed), &sigs); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {type, match[], required}") + } + for i, s := range sigs { + switch s.Type { + case FingerprintSignalHeaderExact, FingerprintSignalHeaderPrefix, FingerprintSignalBodyPath: + default: + return fmt.Errorf("entry %d: type must be one of header_exact/header_prefix/body_path", i) + } + hasMatch := false + for _, m := range s.Match { + if strings.TrimSpace(m) != "" { + hasMatch = true + break + } + } + if !hasMatch { + return fmt.Errorf("entry %d: match must contain at least one non-empty value", i) + } + } + return nil +} + +// DefaultEngineFingerprintSignalsJSON 默认种子的 JSON 字符串。 +func DefaultEngineFingerprintSignalsJSON() string { + b, _ := json.Marshal(DefaultEngineFingerprintSignals) + return string(b) +} diff --git a/backend/internal/pkg/openai/engine_fingerprint_signal_test.go b/backend/internal/pkg/openai/engine_fingerprint_signal_test.go new file mode 100644 index 0000000000..741e0044ab --- /dev/null +++ b/backend/internal/pkg/openai/engine_fingerprint_signal_test.go @@ -0,0 +1,99 @@ +package openai + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// hdr 构造一个 http.Header(键值对)。 +func hdr(kv ...string) http.Header { + h := http.Header{} + for i := 0; i+1 < len(kv); i += 2 { + h.Set(kv[i], kv[i+1]) + } + return h +} + +func TestEvaluateEngineFingerprint_DefaultSeed(t *testing.T) { + sigs := DefaultEngineFingerprintSignals // 仅 x-codex- 前缀 Required + cases := []struct { + name string + h http.Header + body string + want bool + }{ + {"R1 真CLI 带x-codex-window-id", hdr("x-codex-window-id", "a1", "session-id", "u1"), ``, true}, + {"R2 纯伪装 无指纹", hdr("user-agent", "codex/1"), ``, false}, + {"R3 仅body有", hdr(), `{"client_metadata":{"x-codex-window-id":"c3"}}`, false}, + {"R4 旧版 仅session_id无x-codex-", hdr("session_id", "u4"), ``, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, EvaluateEngineFingerprint(tc.h, []byte(tc.body), sigs)) + }) + } +} + +func TestEvaluateEngineFingerprint_Rules(t *testing.T) { + exactSession := EngineFingerprintSignal{Type: FingerprintSignalHeaderExact, Match: []string{"session-id", "session_id"}, Required: true} + prefixCodex := EngineFingerprintSignal{Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: true} + bodyWin := EngineFingerprintSignal{Type: FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id"}, Required: true} + + t.Run("行内变体OR: 配置session-id 命中下划线session_id", func(t *testing.T) { + require.True(t, EvaluateEngineFingerprint(hdr("session_id", "x"), nil, []EngineFingerprintSignal{exactSession})) + }) + t.Run("跨条AND: 勾x-codex-与session 缺一即拒", func(t *testing.T) { + both := []EngineFingerprintSignal{prefixCodex, exactSession} + require.True(t, EvaluateEngineFingerprint(hdr("x-codex-window-id", "a", "session-id", "b"), nil, both)) + require.False(t, EvaluateEngineFingerprint(hdr("session-id", "b"), nil, both)) // 缺 x-codex- + }) + t.Run("body_path 命中/ body空", func(t *testing.T) { + require.True(t, EvaluateEngineFingerprint(hdr(), []byte(`{"client_metadata":{"x-codex-window-id":"1"}}`), []EngineFingerprintSignal{bodyWin})) + require.False(t, EvaluateEngineFingerprint(hdr(), nil, []EngineFingerprintSignal{bodyWin})) + }) + t.Run("无任何Required → true", func(t *testing.T) { + none := []EngineFingerprintSignal{{Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: false}} + require.True(t, EvaluateEngineFingerprint(hdr(), nil, none)) + require.True(t, EvaluateEngineFingerprint(hdr(), nil, nil)) + }) +} + +func TestParseAndValidateEngineFingerprintSignals(t *testing.T) { + t.Run("空串=合法空", func(t *testing.T) { + sigs, ok := ParseEngineFingerprintSignals("") + require.True(t, ok) + require.Nil(t, sigs) + require.NoError(t, ValidateEngineFingerprintSignalsJSON("")) + }) + t.Run("合法数组", func(t *testing.T) { + raw := `[{"type":"header_prefix","match":["x-codex-"],"required":true}]` + sigs, ok := ParseEngineFingerprintSignals(raw) + require.True(t, ok) + require.Len(t, sigs, 1) + require.NoError(t, ValidateEngineFingerprintSignalsJSON(raw)) + }) + t.Run("非法JSON", func(t *testing.T) { + _, ok := ParseEngineFingerprintSignals("not json") + require.False(t, ok) + require.Error(t, ValidateEngineFingerprintSignalsJSON("not json")) + }) + t.Run("非法type 被校验拒绝", func(t *testing.T) { + require.Error(t, ValidateEngineFingerprintSignalsJSON(`[{"type":"bogus","match":["x"]}]`)) + }) + t.Run("match全空 被校验拒绝", func(t *testing.T) { + require.Error(t, ValidateEngineFingerprintSignalsJSON(`[{"type":"header_exact","match":["",""]}]`)) + }) + t.Run("默认种子JSON 可解析且只勾x-codex-", func(t *testing.T) { + sigs, ok := ParseEngineFingerprintSignals(DefaultEngineFingerprintSignalsJSON()) + require.True(t, ok) + requiredTypes := []string{} + for _, s := range sigs { + if s.Required { + requiredTypes = append(requiredTypes, s.Type+":"+s.Match[0]) + } + } + require.Equal(t, []string{"header_prefix:x-codex-"}, requiredTypes) + }) +} diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index ae3886d656..5ecde7f318 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -1,6 +1,9 @@ package openai -import "strings" +import ( + "regexp" + "strings" +) // CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns // Examples: "codex_vscode/1.0.0", "codex_cli_rs/0.1.2" @@ -9,25 +12,49 @@ var CodexCLIUserAgentPrefixes = []string{ "codex_cli_rs/", } -// CodexOfficialClientUserAgentPrefixes matches Codex 官方客户端家族 User-Agent 前缀。 -// 该列表仅用于 OpenAI OAuth `codex_cli_only` 访问限制判定。 -var CodexOfficialClientUserAgentPrefixes = []string{ +// codexOfficialClientUAPrefixes:Codex 官方客户端家族 User-Agent 前缀(均含下划线/连字符, +// 每项都是确定字面量;不含会被 TrimSpace 退化成裸 "codex" 的空格前缀)。 +// 用途:OpenAI OAuth `codex_cli_only` 访问限制判定 + passthrough 的「非官方 UA 安全兜底」 +// (IsCodexOfficialClientRequest 命中即视为官方真实 UA,逐字透传、不改写)。 +// +// Cursor/VSCode 扩展两种 UA:默认 `codex_vscode/`、GitHub Copilot 集成模式 `codex_vscode_copilot/` +// (取证 extension.js `IS="codex_vscode_copilot"` 经 env 注入);交互式 TUI 自报 `codex-tui/` +// (连字符,2026-06-23 审计抽样约占真实流量 35%,必须显式列出)。`Codex Desktop/` 等 `Codex ` +// 前缀家族由 codexOfficialClientFamilyPrefix 单独处理(保留空格,避免退化为裸 codex 的宽松兜底)。 +var codexOfficialClientUAPrefixes = []string{ "codex_cli_rs/", + "codex-tui/", "codex_vscode/", + "codex_vscode_copilot/", "codex_app/", "codex_chatgpt_desktop/", "codex_atlas/", "codex_exec/", "codex_sdk_ts/", - "codex ", } -// CodexOfficialClientOriginatorPrefixes matches Codex 官方客户端家族 originator 前缀。 -// 说明:OpenAI 官方 Codex 客户端并不只使用固定的 codex_app 标识。 -// 例如 codex_cli_rs、codex_vscode、codex_chatgpt_desktop、codex_atlas、codex_exec、codex_sdk_ts 等。 -var CodexOfficialClientOriginatorPrefixes = []string{ - "codex_", - "codex ", +// codexOfficialClientFamilyPrefix 覆盖 `Codex ` 前缀家族(Codex Desktop 等),对应 codex-rs +// is_first_party_originator 的 starts_with("Codex ")。**保留尾随空格**,并以 HasPrefix 直接比对 +// 已归一化(小写 + 去首尾空格)的值——绝不能再经 normalizeCodexClientHeader 处理本前缀,否则 +// 空格被 TrimSpace 去掉、退化成裸 "codex" 而把任何含 codex 的串都放行。 +const codexOfficialClientFamilyPrefix = "codex " + +// codexOfficialClientOriginators:Codex 官方客户端家族 originator 精确集合。 +// app-server `initialize` 把 originator 设为 clientInfo.name 逐字值(codex-rs default_client.rs), +// 故官方集合是这些确定字面量;镜像 is_first_party_originator / is_first_party_chat_originator +// 并叠加 sub2api 已取证变体。用精确匹配而非「含 codex_/codex」的宽松兜底,避免 evil-codex_ 之类 +// 伪造绕过(gate 仍需 UA 双因子佐证)。新官方/合作客户端经 allowed_client.go 命名预设放行, +// 或在 bump context/codex 时同步补入本集合。 +var codexOfficialClientOriginators = map[string]bool{ + "codex_cli_rs": true, // CLI 默认 DEFAULT_ORIGINATOR + "codex-tui": true, // 交互式 TUI(连字符,真实流量占比最高) + "codex_vscode": true, // VSCode/Cursor 扩展 + "codex_vscode_copilot": true, // 扩展 GitHub Copilot 集成模式 + "codex_app": true, // 历史保留 + "codex_chatgpt_desktop": true, // is_first_party_chat_originator + "codex_atlas": true, // is_first_party_chat_originator + "codex_exec": true, // codex exec 非交互 + "codex_sdk_ts": true, // TypeScript SDK } // IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。 @@ -51,22 +78,85 @@ func IsCodexCLIRequest(userAgent string) bool { } // IsCodexOfficialClientRequest checks if the User-Agent indicates a Codex 官方客户端请求。 -// 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。 +// 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。宽松版:官方 UA 前缀集允许 Contains 子串兜底, +// 供 passthrough(IsCodexOfficialClientByHeaders)等历史路径使用,行为不变。 func IsCodexOfficialClientRequest(userAgent string) bool { + return isCodexOfficialClientRequest(userAgent, false) +} + +// IsCodexOfficialClientRequestStrict 同 IsCodexOfficialClientRequest,但官方 UA 前缀集只做前缀 +// 匹配(HasPrefix),不退化为 Contains 子串兜底——专供 codex_cli_only 访问门,收窄「浏览器前缀 + +// 中段 codex token」之类的伪造面。`Codex ` 家族前缀与 UA 尾部兜底保持一致;passthrough 仍用宽松版。 +func IsCodexOfficialClientRequestStrict(userAgent string) bool { + return isCodexOfficialClientRequest(userAgent, true) +} + +// isCodexOfficialClientRequest 匹配层级(优先级由高到低): +// 1. UA 前缀集 codexOfficialClientUAPrefixes(strict=仅 HasPrefix;否则含 Contains 子串兜底) +// 2. `Codex ` 家族前缀(保留空格,避免退化为裸 codex) +// 3. UA 尾部兜底:codex-rs 把 clientInfo.name 写入 UA 末尾括号组 `(name; version)`。 +// CODEX_INTERNAL_ORIGINATOR_OVERRIDE 只改前缀,不改尾部——可借此恢复被 override 的真实 client。 +// 生产审计(10GB / 23 天)显示,originator=cccc 的真实 codex-tui 占全 openai 流量 5.3%, +// 若无此兜底则全部误拒。非官方尾部(如 evil/bash)仍被精确集拒绝。 +func isCodexOfficialClientRequest(userAgent string, strict bool) bool { ua := normalizeCodexClientHeader(userAgent) if ua == "" { return false } - return matchCodexClientHeaderPrefixes(ua, CodexOfficialClientUserAgentPrefixes) + if strict { + if matchCodexClientHeaderStrictPrefixes(ua, codexOfficialClientUAPrefixes) { + return true + } + } else if matchCodexClientHeaderPrefixes(ua, codexOfficialClientUAPrefixes) { + return true + } + if strings.HasPrefix(ua, codexOfficialClientFamilyPrefix) { + return true + } + // UA 尾部兜底:提取最后一个括号组里的 name 段,用官方 originator 检测器判定。 + if name := codexUATrailerName(ua); name != "" { + return IsCodexOfficialClientOriginator(name) + } + return false +} + +// codexUATrailerName extracts the clientInfo.name from the last parenthesized group +// of a codex-rs formatted User-Agent: `{orig}/{ver} ({os}; {arch}) {term} ({name}; {ver})`. +// +// CODEX_INTERNAL_ORIGINATOR_OVERRIDE 修改 UA 前缀(originator 段),但不修改尾部的 +// `(name; version)` 括号组——该组由 codex-rs engine 写入,保留真实 clientInfo.name。 +// 故从尾部提取 name 可以恢复被 override 的真实客户端标识(例如 cccc → codex-tui)。 +// +// input 应为已归一化(小写 + 去首尾空格)的 UA。 +// 若无法解析则返回空字符串。 +func codexUATrailerName(ua string) string { + last := strings.LastIndex(ua, "(") + if last < 0 { + return "" + } + rest := ua[last+1:] + closeIdx := strings.Index(rest, ")") + if closeIdx < 0 { + return "" + } + inner := strings.TrimSpace(rest[:closeIdx]) + if semi := strings.Index(inner, ";"); semi >= 0 { + inner = strings.TrimSpace(inner[:semi]) + } + return inner } // IsCodexOfficialClientOriginator checks if originator indicates a Codex 官方客户端请求。 +// 精确集合匹配 + `Codex ` 家族前缀;不再用「含 codex」宽松兜底(避免伪造绕过)。 func IsCodexOfficialClientOriginator(originator string) bool { v := normalizeCodexClientHeader(originator) if v == "" { return false } - return matchCodexClientHeaderPrefixes(v, CodexOfficialClientOriginatorPrefixes) + if codexOfficialClientOriginators[v] { + return true + } + return strings.HasPrefix(v, codexOfficialClientFamilyPrefix) } // IsCodexOfficialClientByHeaders checks whether the request headers indicate an @@ -92,3 +182,42 @@ func matchCodexClientHeaderPrefixes(value string, prefixes []string) bool { } return false } + +// matchCodexClientHeaderStrictPrefixes 仅前缀匹配(HasPrefix),不含 matchCodexClientHeaderPrefixes +// 的 Contains 子串兜底。用于 codex_cli_only 官方门收窄伪造面;passthrough 历史路径仍用宽松版。 +// value 应为已归一化(小写 + 去首尾空格)的值。 +func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool { + for _, prefix := range prefixes { + if p := normalizeCodexClientHeader(prefix); p != "" && strings.HasPrefix(value, p) { + return true + } + } + return false +} + +// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。 +var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) + +// ParseCodexEngineVersion 从 codex-rs 形态 UA 取引擎版本: +// `{originator}/{X.Y.Z} (...)`,第一个 '/' 后、首个空格或 '(' 前的三段版本。 +// 该版本是 codex-rs CARGO_PKG_VERSION(引擎版本,CLI/app-server 一致)。 +func ParseCodexEngineVersion(ua string) (string, bool) { + ua = strings.TrimSpace(ua) + slash := strings.IndexByte(ua, '/') + if slash < 0 { + return "", false + } + rest := ua[slash+1:] + end := len(rest) + for i := 0; i < len(rest); i++ { + if rest[i] == ' ' || rest[i] == '(' { + end = i + break + } + } + m := codexEngineVersionPattern.FindString(strings.TrimSpace(rest[:end])) + if m == "" { + return "", false + } + return m, true +} diff --git a/backend/internal/pkg/openai/request_test.go b/backend/internal/pkg/openai/request_test.go index b4562a07de..78bcf9c752 100644 --- a/backend/internal/pkg/openai/request_test.go +++ b/backend/internal/pkg/openai/request_test.go @@ -27,6 +27,35 @@ func TestIsCodexCLIRequest(t *testing.T) { } } +func TestCodexUATrailerName(t *testing.T) { + tests := []struct { + name string + ua string + want string + }{ + // 典型 cccc override 场景:前缀改写但尾部保留真实 clientInfo.name + {name: "cccc override → codex-tui", ua: "cccc/0.141.0 (mac os 14.6.1; arm64) apple_terminal/453 (codex-tui; 0.141.0)", want: "codex-tui"}, + {name: "cccc override Ubuntu", ua: "cccc/0.139.0 (ubuntu 22.4.0; x86_64) screen (codex-tui; 0.139.0)", want: "codex-tui"}, + // 官方客户端自报名称 + {name: "Codex Desktop 自报(小写后)", ua: "codex desktop/0.142.0 (mac os 26.0.1; arm64) unknown (codex desktop; 26.616.71553)", want: "codex desktop"}, + {name: "codex-tui 自报", ua: "codex-tui/0.141.0 (mac os 15.5.0; arm64) ghostty/1.3.1 (codex-tui; 0.141.0)", want: "codex-tui"}, + {name: "codex_exec 自报", ua: "codex_exec/0.141.0 (mac os 14.7.3; arm64) apple_terminal (codex_exec; 0.141.0)", want: "codex_exec"}, + // 无括号/无尾部组 + {name: "curl 无括号", ua: "curl/8.0.1", want: ""}, + {name: "空字符串", ua: "", want: ""}, + // 非 codex 尾部 + {name: "非 codex 尾部不影响", ua: "evil/0.1.0 (linux; x86_64) bash (evil; 0.1.0)", want: "evil"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := codexUATrailerName(tt.ua) + if got != tt.want { + t.Fatalf("codexUATrailerName(%q) = %q, want %q", tt.ua, got, tt.want) + } + }) + } +} + func TestIsCodexOfficialClientRequest(t *testing.T) { tests := []struct { name string @@ -35,14 +64,23 @@ func TestIsCodexOfficialClientRequest(t *testing.T) { }{ {name: "codex_cli_rs 前缀", ua: "codex_cli_rs/0.98.0", want: true}, {name: "codex_vscode 前缀", ua: "codex_vscode/1.0.0", want: true}, + {name: "codex_vscode_copilot 变体前缀", ua: "codex_vscode_copilot/0.140.0", want: true}, {name: "codex_app 前缀", ua: "codex_app/0.1.0", want: true}, {name: "codex_chatgpt_desktop 前缀", ua: "codex_chatgpt_desktop/1.0.0", want: true}, {name: "codex_atlas 前缀", ua: "codex_atlas/1.0.0", want: true}, {name: "codex_exec 前缀", ua: "codex_exec/0.1.0", want: true}, {name: "codex_sdk_ts 前缀", ua: "codex_sdk_ts/0.1.0", want: true}, {name: "Codex 桌面 UA", ua: "Codex Desktop/1.2.3", want: true}, + {name: "codex-tui 连字符前缀(真实流量占比最高)", ua: "codex-tui/0.141.0 (Mac OS 15.5.0; arm64) ghostty/1.3.1 (codex-tui; 0.141.0)", want: true}, {name: "复合 UA 包含 codex_app", ua: "Mozilla/5.0 codex_app/0.1.0", want: true}, {name: "大小写混合", ua: "Codex_VSCode/1.2.3", want: true}, + // UA 尾部兜底:cccc 是生产中 CODEX_INTERNAL_ORIGINATOR_OVERRIDE=cccc 的真实 codex-tui。 + // 审计 10GB/23天 中占非 codex 的 80.9%(494/611)、全 openai 流量的 5.3%——若不兜底会误杀。 + {name: "cccc override Mac → 尾部兜底放行", ua: "cccc/0.141.0 (Mac OS 14.6.1; arm64) Apple_Terminal/453 (codex-tui; 0.141.0)", want: true}, + {name: "cccc override Ubuntu → 尾部兜底放行", ua: "cccc/0.139.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.139.0)", want: true}, + {name: "cccc override iTerm → 尾部兜底放行", ua: "cccc/0.137.0 (Mac OS 26.1.0; arm64) iTerm.app/3.4.22 (codex-tui; 0.137.0)", want: true}, + // 非 codex 尾部不应放行 + {name: "完全伪造尾部应拒", ua: "evil/0.1.0 (Linux; x86_64) bash (evil; 0.1.0)", want: false}, {name: "非 codex", ua: "curl/8.0.1", want: false}, {name: "空字符串", ua: "", want: false}, } @@ -71,7 +109,10 @@ func TestIsCodexOfficialClientOriginator(t *testing.T) { {name: "codex_exec", originator: "codex_exec", want: true}, {name: "codex_sdk_ts", originator: "codex_sdk_ts", want: true}, {name: "Codex 前缀", originator: "Codex Desktop", want: true}, + {name: "codex-tui 连字符(真实流量占比最高)", originator: "codex-tui", want: true}, {name: "空白包裹", originator: " codex_vscode ", want: true}, + {name: "伪造含 codex_ 子串应拒(L2 收紧)", originator: "evil-codex_cli", want: false}, + {name: "codex_ 混入中段应拒(L2 收紧)", originator: "my_codex_thing", want: false}, {name: "非 codex", originator: "my_client", want: false}, {name: "空字符串", originator: "", want: false}, } @@ -86,6 +127,36 @@ func TestIsCodexOfficialClientOriginator(t *testing.T) { } } +func TestIsCodexOfficialClientRequestStrict(t *testing.T) { + tests := []struct { + name string + ua string + want bool + }{ + // 前缀开头:与 lax 版一致放行 + {name: "codex_cli_rs 前缀开头", ua: "codex_cli_rs/0.141.0 (x)", want: true}, + {name: "codex_vscode 前缀开头", ua: "codex_vscode/1.0.0", want: true}, + {name: "codex_app 前缀开头", ua: "codex_app/2.1.0", want: true}, + {name: "Codex 家族前缀保留", ua: "Codex Desktop/1.2.3", want: true}, + {name: "大小写混合前缀开头", ua: "Codex_CLI_Rs/0.141.0", want: true}, + // UA 尾部兜底保留:cccc override 真实 codex-tui 仍放行 + {name: "cccc override 尾部兜底仍放行", ua: "cccc/0.141.0 (Mac OS 14.6.1; arm64) Apple_Terminal/453 (codex-tui; 0.141.0)", want: true}, + // N1 收紧:codex token 不在行首(子串)不再算官方——lax 版会因 Contains 误判 true + {name: "浏览器前缀+中段 codex_app 收紧→拒", ua: "Mozilla/5.0 codex_app/0.141.0", want: false}, + {name: "中段 codex_cli_rs 收紧→拒", ua: "evilclient/1.0 codex_cli_rs/0.141.0", want: false}, + {name: "非 codex", ua: "curl/8.0.1", want: false}, + {name: "空字符串", ua: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsCodexOfficialClientRequestStrict(tt.ua) + if got != tt.want { + t.Fatalf("IsCodexOfficialClientRequestStrict(%q) = %v, want %v", tt.ua, got, tt.want) + } + }) + } +} + func TestIsCodexOfficialClientByHeaders(t *testing.T) { tests := []struct { name string @@ -96,6 +167,10 @@ func TestIsCodexOfficialClientByHeaders(t *testing.T) { {name: "仅 originator 命中 desktop", originator: "Codex Desktop", want: true}, {name: "仅 originator 命中 vscode", originator: "codex_vscode", want: true}, {name: "仅 ua 命中 desktop", ua: "Codex Desktop/1.2.3", want: true}, + {name: "仅 originator 命中 codex-tui", originator: "codex-tui", want: true}, + {name: "仅 ua 命中 codex-tui", ua: "codex-tui/0.141.0 (Mac OS 15.5.0; arm64) ghostty/1.3.1", want: true}, + // cccc:originator 不命中精确集,但 UA 尾部兜底恢复真实 codex-tui + {name: "cccc override → UA 尾部兜底放行(审计 5.3% 误杀场景)", ua: "cccc/0.141.0 (Mac OS 14.6.1; arm64) Apple_Terminal/453 (codex-tui; 0.141.0)", originator: "cccc", want: true}, {name: "ua 与 originator 都未命中", ua: "curl/8.0.1", originator: "my_client", want: false}, } diff --git a/backend/internal/pkg/openai/request_version_test.go b/backend/internal/pkg/openai/request_version_test.go new file mode 100644 index 0000000000..6614566454 --- /dev/null +++ b/backend/internal/pkg/openai/request_version_test.go @@ -0,0 +1,32 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCodexEngineVersion(t *testing.T) { + cases := []struct { + name string + ua string + wantVer string + wantOK bool + }{ + {"cli", "codex_cli_rs/0.141.0 (Ubuntu 22.4.0; x86_64) xterm", "0.141.0", true}, + {"tui trailer", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", "0.140.2", true}, + {"cccc override prefix", "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", "0.142.0", true}, + {"desktop space prefix", "Codex Desktop/0.139.0 (Mac OS X 14; arm64) unknown", "0.139.0", true}, + {"alpha suffix keeps xyz", "codex_cli_rs/0.143.0-alpha.2 (Ubuntu; x86_64) x", "0.143.0", true}, + {"no slash", "curl 8.0", "", false}, + {"non numeric", "codex_cli_rs/abc (x)", "", false}, + {"empty", "", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ver, ok := ParseCodexEngineVersion(tc.ua) + require.Equal(t, tc.wantOK, ok) + require.Equal(t, tc.wantVer, ver) + }) + } +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 57d2092605..e925e54121 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -832,6 +832,12 @@ func TestAPIContracts(t *testing.T) { "table_page_size_options": [10, 20, 50, 100], "min_claude_code_version": "", "max_claude_code_version": "", + "min_codex_version": "", + "max_codex_version": "", + "codex_cli_only_blacklist": "", + "codex_cli_only_whitelist": "", + "codex_cli_only_allow_app_server_clients": false, + "codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]", "allow_ungrouped_key_scheduling": false, "backend_mode_enabled": false, "enable_cch_signing": false, @@ -850,7 +856,6 @@ 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": [] }, @@ -1086,6 +1091,12 @@ func TestAPIContracts(t *testing.T) { "enable_anthropic_cache_ttl_1h_injection": false, "rewrite_message_cache_control": false, "antigravity_user_agent_version": "", + "min_codex_version": "", + "max_codex_version": "", + "codex_cli_only_blacklist": "", + "codex_cli_only_whitelist": "", + "codex_cli_only_allow_app_server_clients": false, + "codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]", "web_search_emulation_enabled": false, "payment_visible_method_alipay_source": "", "payment_visible_method_wxpay_source": "", @@ -1093,7 +1104,6 @@ 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 5c3ac71ea2..ffd73d2d80 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1631,36 +1631,15 @@ 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 +// IsCodexCLIOnlyAppServerAllowed 返回 codex_cli_only 账号是否额外放行 Codex app-server +// 第三方客户端(运行时与全局 app_server 开关 OR)。字段:accounts.extra.codex_cli_only_allow_app_server。 +// 仅在 codex_cli_only 已启用时有意义;字段缺失或类型不符按 false(不放行)处理。 +func (a *Account) IsCodexCLIOnlyAppServerAllowed() bool { + if !a.IsCodexCLIOnlyEnabled() { + return false } - 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 + v, ok := a.Extra["codex_cli_only_allow_app_server"].(bool) + return ok && v } // WindowCostSchedulability 窗口费用调度状态 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 deleted file mode 100644 index c835ea2781..0000000000 --- a/backend/internal/service/account_codex_cli_only_allowed_clients_test.go +++ /dev/null @@ -1,68 +0,0 @@ -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/account_codex_cli_only_app_server_test.go b/backend/internal/service/account_codex_cli_only_app_server_test.go new file mode 100644 index 0000000000..67d4f28420 --- /dev/null +++ b/backend/internal/service/account_codex_cli_only_app_server_test.go @@ -0,0 +1,45 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccount_IsCodexCLIOnlyAppServerAllowed(t *testing.T) { + t.Run("codex_cli_only 开 + allow_app_server=true → true", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true, "codex_cli_only_allow_app_server": true}, + } + require.True(t, account.IsCodexCLIOnlyAppServerAllowed()) + }) + + t.Run("codex_cli_only 开 + allow_app_server=false → false", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true, "codex_cli_only_allow_app_server": false}, + } + require.False(t, account.IsCodexCLIOnlyAppServerAllowed()) + }) + + t.Run("codex_cli_only 开 + 字段缺失 → false", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only": true}, + } + require.False(t, account.IsCodexCLIOnlyAppServerAllowed()) + }) + + t.Run("codex_cli_only 关 → 即便 allow_app_server=true 也 false", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"codex_cli_only_allow_app_server": true}, + } + require.False(t, account.IsCodexCLIOnlyAppServerAllowed()) + }) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 7dd5ec6323..c83b683670 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -408,6 +408,21 @@ const ( // SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查) SettingKeyMinClaudeCodeVersion = "min_claude_code_version" + // SettingKeyMinCodexVersion 最低 Codex 引擎版本要求 (semver, 如 "0.141.0",空值=不检查) + SettingKeyMinCodexVersion = "min_codex_version" + // SettingKeyMaxCodexVersion 最高 Codex 引擎版本限制 (semver, 如 "0.200.0",空值=不检查) + SettingKeyMaxCodexVersion = "max_codex_version" + // SettingKeyCodexCLIOnlyBlacklist codex_cli_only 全局黑名单([]AllowedClientEntry JSON,OR deny)。 + SettingKeyCodexCLIOnlyBlacklist = "codex_cli_only_blacklist" + // SettingKeyCodexCLIOnlyWhitelist codex_cli_only 全局白名单([]AllowedClientEntry JSON,双因子 AND allow)。 + SettingKeyCodexCLIOnlyWhitelist = "codex_cli_only_whitelist" + // SettingKeyCodexCLIOnlyAllowAppServerClients App Server 开关:对未列名客户端开闸(默认 false;仅显式 "true" 开)。 + SettingKeyCodexCLIOnlyAllowAppServerClients = "codex_cli_only_allow_app_server_clients" + // SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint 引擎门 body 通道开关:接受 client_metadata 引擎指纹(默认 false;仅显式 "true" 开)。(已废弃,迁移并入信号列表) + SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint = "codex_cli_only_allow_body_engine_fingerprint" + // SettingKeyCodexCLIOnlyEngineFingerprintSignals codex_cli_only 引擎指纹门信号列表([]EngineFingerprintSignal JSON)。 + // 勾选(required)信号之间 AND;每条 match 变体行内 OR;缺失/空/非法 → 默认种子(只勾 x-codex-)。 + SettingKeyCodexCLIOnlyEngineFingerprintSignals = "codex_cli_only_engine_fingerprint_signals" // SettingKeyMaxClaudeCodeVersion 最高 Claude Code 版本号限制 (semver, 如 "3.0.0",空值=不检查) SettingKeyMaxClaudeCodeVersion = "max_claude_code_version" @@ -443,8 +458,8 @@ 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 已废弃:历史全局开关只作为升级迁移输入读取。 + // 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。 SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" // 余额不足提醒 diff --git a/backend/internal/service/openai_client_restriction_detector.go b/backend/internal/service/openai_client_restriction_detector.go index 8589737ade..abca88ce66 100644 --- a/backend/internal/service/openai_client_restriction_detector.go +++ b/backend/internal/service/openai_client_restriction_detector.go @@ -1,6 +1,8 @@ package service import ( + "net/http" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" @@ -13,16 +15,37 @@ 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 表示请求未命中任何允许的客户端身份。 CodexClientRestrictionReasonNotMatchedUA = "official_client_user_agent_not_matched" // CodexClientRestrictionReasonForceCodexCLI 表示通过 ForceCodexCLI 配置兜底放行。 CodexClientRestrictionReasonForceCodexCLI = "force_codex_cli_enabled" + // CodexClientRestrictionReasonBlacklisted 表示请求命中全局黑名单(门内 deny 最先,OR 语义)。 + CodexClientRestrictionReasonBlacklisted = "blacklist_matched" + // CodexClientRestrictionReasonMatchedWhitelistClient 表示请求命中全局自由白名单条目(双因子 AND)。 + CodexClientRestrictionReasonMatchedWhitelistClient = "whitelist_client_matched" + // CodexClientRestrictionReasonVersionTooLow 表示 UA 解析出的 Codex 引擎版本低于最低要求。 + CodexClientRestrictionReasonVersionTooLow = "codex_version_too_low" + // CodexClientRestrictionReasonMissingEngineFingerprint 表示 strict 指纹门下缺少 codex 引擎指纹头。 + CodexClientRestrictionReasonMissingEngineFingerprint = "missing_engine_fingerprint" + // CodexClientRestrictionReasonVersionUndetectable 表示 codex_cli_only 下无法从 UA 解析出 Codex 引擎版本。 + CodexClientRestrictionReasonVersionUndetectable = "codex_version_undetectable" + // CodexClientRestrictionReasonVersionTooHigh 表示 UA 解析出的 Codex 引擎版本高于最高允许版本。 + CodexClientRestrictionReasonVersionTooHigh = "codex_version_too_high" + // CodexClientRestrictionReasonMatchedAppServerClient 表示 App Server 开关开启时对未列名客户端开闸放行(仍过引擎门)。 + CodexClientRestrictionReasonMatchedAppServerClient = "app_server_client_matched" ) +// CodexRestrictionPolicy 是 codex_cli_only 判定所需的全局策略快照,由调用方从全局设置解析注入(global-only)。 +// 账号侧只有 codex_cli_only 开关本身;黑/白名单、最低版本、指纹门均为全局设置。 +type CodexRestrictionPolicy struct { + Whitelist []openai.AllowedClientEntry // 全局自由白名单(双因子 AND,放行官方集未覆盖的 app-server client) + Blacklist []openai.AllowedClientEntry // 全局自由黑名单(OR,宽 deny) + MinCodexVersion string // 最低 Codex 引擎版本 semver;""=不校验 + MaxCodexVersion string // 最高 Codex 引擎版本 semver;""=不校验 + AllowAppServerClients bool // App Server 开关:对未列名客户端开闸(仍受引擎门约束) + EngineFingerprintSignals []openai.EngineFingerprintSignal // 引擎指纹门信号列表(勾选 AND / 行内变体 OR);缺省=默认种子(只勾 x-codex-) +} + // CodexClientRestrictionDetectionResult 是 codex_cli_only 统一检测入口结果。 type CodexClientRestrictionDetectionResult struct { Enabled bool @@ -32,7 +55,7 @@ type CodexClientRestrictionDetectionResult struct { // CodexClientRestrictionDetector 定义 codex_cli_only 统一检测入口。 type CodexClientRestrictionDetector interface { - Detect(c *gin.Context, account *Account, globalAllowedClients []string) CodexClientRestrictionDetectionResult + Detect(c *gin.Context, account *Account, policy CodexRestrictionPolicy, body []byte) CodexClientRestrictionDetectionResult } // OpenAICodexClientRestrictionDetector 为 OpenAI OAuth codex_cli_only 的默认实现。 @@ -44,67 +67,81 @@ func NewOpenAICodexClientRestrictionDetector(cfg *config.Config) *OpenAICodexCli return &OpenAICodexClientRestrictionDetector{cfg: cfg} } -func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account, globalAllowedClients []string) CodexClientRestrictionDetectionResult { +// Detect 门控顺序(每步可短路): +// 1. 账号未开 codex_cli_only → 不限制(Disabled)。 +// 2. gateway.force_codex_cli → 全局旁路放行(ForceCodexCLI)。 +// 3. 黑名单命中 → 立即拒(门内 deny 最先,OR 语义)。 +// 4. 身份候选:官方 UA / 官方 originator / 全局白名单 / App Server 开闸(全局开关 OR 账号开关);都不命中 → 拒(NotMatchedUA)。 +// 5. Codex 版本(仅官方候选):版本必须可解析(否则 VersionUndetectable);< Min → 拒(TooLow);> Max → 拒(TooHigh)。 +// 6. 引擎指纹 AND 硬门:按 EngineFingerprintSignals 列表勾选 AND 判定(无任何 Required 信号→放行,即「关闭指纹门」=取消所有勾选);白名单条目可显式 skip。 +func (d *OpenAICodexClientRestrictionDetector) Detect(c *gin.Context, account *Account, policy CodexRestrictionPolicy, body []byte) CodexClientRestrictionDetectionResult { if account == nil || !account.IsCodexCLIOnlyEnabled() { - return CodexClientRestrictionDetectionResult{ - Enabled: false, - Matched: false, - Reason: CodexClientRestrictionReasonDisabled, - } + return CodexClientRestrictionDetectionResult{Enabled: false, Matched: false, Reason: CodexClientRestrictionReasonDisabled} } if d != nil && d.cfg != nil && d.cfg.Gateway.ForceCodexCLI { - return CodexClientRestrictionDetectionResult{ - Enabled: true, - Matched: true, - Reason: CodexClientRestrictionReasonForceCodexCLI, - } + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: CodexClientRestrictionReasonForceCodexCLI} } userAgent := "" originator := "" + var header http.Header if c != nil { userAgent = c.GetHeader("User-Agent") originator = c.GetHeader("originator") - } - if openai.IsCodexOfficialClientRequest(userAgent) { - return CodexClientRestrictionDetectionResult{ - Enabled: true, - Matched: true, - Reason: CodexClientRestrictionReasonMatchedUA, - } - } - if openai.IsCodexOfficialClientOriginator(originator) { - return CodexClientRestrictionDetectionResult{ - Enabled: true, - Matched: true, - Reason: CodexClientRestrictionReasonMatchedOriginator, + if c.Request != nil { + header = c.Request.Header } } - // 官方客户端白名单未命中时,先尝试账号级额外放行的命名客户端预设(如 Claude Code codex 插件)。 - if allowed := account.GetCodexCLIOnlyAllowedClients(); len(allowed) > 0 && - openai.MatchAllowedClients(userAgent, originator, allowed) { - return CodexClientRestrictionDetectionResult{ - Enabled: true, - Matched: true, - Reason: CodexClientRestrictionReasonMatchedAllowedClient, + // 3. 黑名单优先(门内 deny 最先,OR:任一已声明字段命中即拒)。 + if openai.MatchDenyEntries(userAgent, originator, policy.Blacklist) { + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonBlacklisted} + } + + // 4. 身份候选(优先级:官方 > 全局白名单 > App Server 开闸:全局开关 OR 账号开关)。 + reason := "" + skipFingerprint := false + switch { + case openai.IsCodexOfficialClientRequestStrict(userAgent): + reason = CodexClientRestrictionReasonMatchedUA + case openai.IsCodexOfficialClientOriginator(originator): + reason = CodexClientRestrictionReasonMatchedOriginator + default: + if entry, ok := openai.MatchClientEntry(userAgent, originator, policy.Whitelist); ok { + reason = CodexClientRestrictionReasonMatchedWhitelistClient + skipFingerprint = entry.SkipEngineFingerprint + } else if policy.AllowAppServerClients || account.IsCodexCLIOnlyAppServerAllowed() { + reason = CodexClientRestrictionReasonMatchedAppServerClient + } + } + if reason == "" { + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonNotMatchedUA} + } + + // 5. Codex 版本:仅对官方候选(官方 UA / 官方 originator)。版本必须可识别(需求②),再校验 [min,max]。 + // 白名单/账号预设/App Server 候选可能不带可解析引擎版本,整块跳过。 + if reason == CodexClientRestrictionReasonMatchedUA || reason == CodexClientRestrictionReasonMatchedOriginator { + ver, ok := openai.ParseCodexEngineVersion(userAgent) + if !ok { + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionUndetectable} + } + if policy.MinCodexVersion != "" && CompareVersions(ver, policy.MinCodexVersion) < 0 { + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionTooLow} + } + if policy.MaxCodexVersion != "" && CompareVersions(ver, policy.MaxCodexVersion) > 0 { + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonVersionTooHigh} } } - // 再尝试由更高作用域(全局设置)注入的额外放行客户端列表。 - if len(globalAllowedClients) > 0 && - openai.MatchAllowedClients(userAgent, originator, globalAllowedClients) { - return CodexClientRestrictionDetectionResult{ - Enabled: true, - Matched: true, - Reason: CodexClientRestrictionReasonMatchedGlobalAllowedClient, + // 6. 引擎指纹 AND 硬门。对所有候选生效;唯一例外:命中的白名单条目显式 SkipEngineFingerprint。 + // 按全局信号列表判定:所有勾选(Required)信号都命中即放行,每条命中任一变体即满足(行内 OR); + // 无任何勾选信号 → 视为无要求放行(即「关闭指纹门」=取消所有勾选)。ForceCodexCLI 与黑名单不经此门。 + if !skipFingerprint { + if !openai.EvaluateEngineFingerprint(header, body, policy.EngineFingerprintSignals) { + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: false, Reason: CodexClientRestrictionReasonMissingEngineFingerprint} } } - return CodexClientRestrictionDetectionResult{ - Enabled: true, - Matched: false, - Reason: CodexClientRestrictionReasonNotMatchedUA, - } + return CodexClientRestrictionDetectionResult{Enabled: true, Matched: true, Reason: reason} } diff --git a/backend/internal/service/openai_client_restriction_detector_hardening_test.go b/backend/internal/service/openai_client_restriction_detector_hardening_test.go new file mode 100644 index 0000000000..c99fcac690 --- /dev/null +++ b/backend/internal/service/openai_client_restriction_detector_hardening_test.go @@ -0,0 +1,136 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func hdrCtx(h map[string]string) *gin.Context { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + for k, v := range h { + c.Request.Header.Set(k, v) + } + return c +} + +func codexOnlyAccount() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} +} + +func TestDetect_N1_StrictOfficialUA(t *testing.T) { + det := NewOpenAICodexClientRestrictionDetector(nil) + acc := codexOnlyAccount() + // 构造「中段 codex token」伪装:首段带可解析版本(绕过版本门),codex_app 在中段。 + // 旧 lax(Contains) 会判为官方 UA → 放行(空策略指纹门开放);N1 收紧后应判非官方 → NotMatchedUA。 + ua := "x/0.141.0 codex_app/0.141.0" + r := det.Detect(hdrCtx(map[string]string{"User-Agent": ua}), acc, CodexRestrictionPolicy{}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, r.Reason) +} + +func TestDetectCodexClientRestriction_NilSettingServiceFailsClosed(t *testing.T) { + gin.SetMode(gin.TestMode) + // settingService 缺失(仅测试/误配可达):账号已开 codex_cli_only、官方 UA、但无 x-codex- 指纹头。 + // 零值 policy 不得让指纹门失败开放——gateway 应回退默认种子指纹信号并拒(MissingEngineFingerprint)。 + s := &OpenAIGatewayService{} + r := s.detectCodexClientRestriction(hdrCtx(map[string]string{"User-Agent": "codex_cli_rs/0.141.0 (x)"}), codexOnlyAccount(), nil) + require.True(t, r.Enabled) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason) +} + +func TestDetect_Hardening(t *testing.T) { + det := NewOpenAICodexClientRestrictionDetector(nil) + acc := codexOnlyAccount() + fp := map[string]string{"x-codex-installation-id": "i1"} // 引擎指纹 + + t.Run("黑名单优先于官方身份", func(t *testing.T) { + pol := CodexRestrictionPolicy{Blacklist: []openai.AllowedClientEntry{{Originator: "codex_cli_rs"}}} + h := map[string]string{"User-Agent": "codex_cli_rs/0.141.0 (x)", "originator": "codex_cli_rs", "x-codex-installation-id": "i1"} + r := det.Detect(hdrCtx(h), acc, pol, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonBlacklisted, r.Reason) + }) + + t.Run("strict 指纹缺失→拒(即便官方 UA)", func(t *testing.T) { + r := det.Detect(hdrCtx(map[string]string{"User-Agent": "codex_cli_rs/0.141.0 (x)"}), acc, CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason) + }) + + t.Run("strict 带指纹→放行", func(t *testing.T) { + h := map[string]string{"User-Agent": "codex_cli_rs/0.141.0 (x)"} + for k, v := range fp { + h[k] = v + } + r := det.Detect(hdrCtx(h), acc, CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}, nil) + require.True(t, r.Matched) + }) + + t.Run("版本过低→拒", func(t *testing.T) { + h := map[string]string{"User-Agent": "codex_cli_rs/0.130.0 (x)"} + for k, v := range fp { + h[k] = v + } + r := det.Detect(hdrCtx(h), acc, CodexRestrictionPolicy{MinCodexVersion: "0.141.0"}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonVersionTooLow, r.Reason) + }) + + t.Run("白名单 app-server 新 client + 指纹→放行", func(t *testing.T) { + h := map[string]string{"User-Agent": "opencode/0.141.0 (x)", "originator": "opencode"} + for k, v := range fp { + h[k] = v + } + pol := CodexRestrictionPolicy{ + Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}}}, + EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals, + } + r := det.Detect(hdrCtx(h), acc, pol, nil) + require.True(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, r.Reason) + }) + + t.Run("非 codex 不命中→拒", func(t *testing.T) { + r := det.Detect(hdrCtx(map[string]string{"User-Agent": "curl/8"}), acc, CodexRestrictionPolicy{}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, r.Reason) + }) + + t.Run("版本不可识别→拒(originator 命中但 UA 无版本)", func(t *testing.T) { + h := map[string]string{"User-Agent": "curl/8.0", "originator": "codex_chatgpt_desktop"} + for k, v := range fp { + h[k] = v + } + r := det.Detect(hdrCtx(h), acc, CodexRestrictionPolicy{}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonVersionUndetectable, r.Reason) + }) + + t.Run("版本过高→拒", func(t *testing.T) { + h := map[string]string{"User-Agent": "codex_cli_rs/0.200.0 (x)"} + for k, v := range fp { + h[k] = v + } + r := det.Detect(hdrCtx(h), acc, CodexRestrictionPolicy{MaxCodexVersion: "0.141.0"}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonVersionTooHigh, r.Reason) + }) + + t.Run("max 边界(==max)放行", func(t *testing.T) { + h := map[string]string{"User-Agent": "codex_cli_rs/0.141.0 (x)"} + for k, v := range fp { + h[k] = v + } + r := det.Detect(hdrCtx(h), acc, CodexRestrictionPolicy{MaxCodexVersion: "0.141.0"}, nil) + require.True(t, r.Matched) + }) +} diff --git a/backend/internal/service/openai_client_restriction_detector_test.go b/backend/internal/service/openai_client_restriction_detector_test.go index fc115128cb..291c79f6bf 100644 --- a/backend/internal/service/openai_client_restriction_detector_test.go +++ b/backend/internal/service/openai_client_restriction_detector_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -30,7 +31,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, nil) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", ""), account, CodexRestrictionPolicy{}, nil) require.False(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonDisabled, result.Reason) @@ -44,7 +45,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, nil) + result := detector.Detect(newCodexDetectorTestContext("codex_cli_rs/0.99.0", ""), account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -58,7 +59,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, nil) + result := detector.Detect(newCodexDetectorTestContext("codex_vscode/1.0.0", ""), account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -72,7 +73,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, nil) + result := detector.Detect(newCodexDetectorTestContext("codex_app/2.1.0", ""), account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedUA, result.Reason) @@ -86,7 +87,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, nil) + result := detector.Detect(newCodexDetectorTestContext("myterm/0.141.0", "codex_chatgpt_desktop"), account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonMatchedOriginator, result.Reason) @@ -100,7 +101,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, nil) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) @@ -116,7 +117,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, nil) + result := detector.Detect(newCodexDetectorTestContext("curl/8.0", "my_client"), account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason) @@ -131,40 +132,6 @@ func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T 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{ @@ -173,27 +140,27 @@ func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T Extra: map[string]any{"codex_cli_only": true}, } - result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, nil) + result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, CodexRestrictionPolicy{}, 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) { + 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"}}, + Extra: map[string]any{}, } - result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, nil) + result := detector.Detect(newCodexDetectorTestContext(claudeCodeUA, claudeCodeOriginator), account, CodexRestrictionPolicy{}, 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) { + t.Run("全局白名单含 Claude Code 签名 → 放行(whitelist)", func(t *testing.T) { detector := NewOpenAICodexClientRestrictionDetector(nil) account := &Account{ Platform: PlatformOpenAI, @@ -203,21 +170,27 @@ func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T 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"}, + CodexRestrictionPolicy{Whitelist: []openai.AllowedClientEntry{{Originator: "Claude Code", UAContains: []string{"Claude Code/"}}}}, + nil, ) require.True(t, result.Enabled) require.True(t, result.Matched) - require.Equal(t, CodexClientRestrictionReasonMatchedGlobalAllowedClient, result.Reason) + require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, result.Reason) }) - t.Run("全局列表含 claude_code + 非签名 → 403", func(t *testing.T) { + 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"}) + result := detector.Detect( + newCodexDetectorTestContext("curl/8.0", "my_client"), + account, + CodexRestrictionPolicy{Whitelist: []openai.AllowedClientEntry{{Originator: "Claude Code", UAContains: []string{"Claude Code/"}}}}, + nil, + ) require.True(t, result.Enabled) require.False(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, result.Reason) @@ -233,6 +206,7 @@ func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T result := detector.Detect( newCodexDetectorTestContext("Claude Code/0.5.0 (Macos) (Claude Code; 1.0.4)", "Claude Code"), account, + CodexRestrictionPolicy{}, nil, ) require.True(t, result.Enabled) @@ -240,22 +214,149 @@ func TestOpenAICodexClientRestrictionDetector_Detect_AllowedClients(t *testing.T 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"}, - }, +} + +func TestDetect_V3_AppServerAndSkipAndVersionScope(t *testing.T) { + gin.SetMode(gin.TestMode) + acc := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} + } + + t.Run("AppServer OFF:未列名客户端拒", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), CodexRestrictionPolicy{}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, r.Reason) + }) + + t.Run("AppServer ON + 引擎头 → 放行(app_server)", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + c := newCodexDetectorTestContext("opencode/1.0", "opencode") + c.Request.Header.Set("x-codex-window-id", "1") + r := d.Detect(c, acc(), CodexRestrictionPolicy{AllowAppServerClients: true, EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}, nil) + require.True(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedAppServerClient, r.Reason) + }) + + t.Run("AppServer ON + 无引擎头 + strict → 拒", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), CodexRestrictionPolicy{AllowAppServerClients: true, EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason) + }) + + t.Run("白名单 skip=true + 无引擎头 + strict → 放行", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + pol := CodexRestrictionPolicy{ + Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}, SkipEngineFingerprint: true}}, } - 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) + r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), pol, nil) + require.True(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, r.Reason) + }) + + t.Run("白名单 skip=false + 无引擎头 + strict → 拒", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + pol := CodexRestrictionPolicy{ + EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals, + Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}}}, + } + r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acc(), pol, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason) + }) + + t.Run("版本门仅官方:白名单无版本不拒", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + pol := CodexRestrictionPolicy{ + Whitelist: []openai.AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode"}}}, + } + r := d.Detect(newCodexDetectorTestContext("opencode", "opencode"), acc(), pol, nil) + require.True(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedWhitelistClient, r.Reason) + }) + + t.Run("版本门仍卡官方:官方 originator 无版本 → VersionUndetectable", func(t *testing.T) { + d := NewOpenAICodexClientRestrictionDetector(nil) + r := d.Detect(newCodexDetectorTestContext("noversion", "codex_cli_rs"), acc(), CodexRestrictionPolicy{}, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonVersionUndetectable, r.Reason) + }) +} + +func TestDetect_EngineFingerprintSignals(t *testing.T) { + gin.SetMode(gin.TestMode) + det := NewOpenAICodexClientRestrictionDetector(&config.Config{}) + acct := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} + + officialUA := "codex_cli_rs/0.141.0 (x) (codex_cli_rs; 0.141.0)" + policy := CodexRestrictionPolicy{ + EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals, // 只勾 x-codex- + } + + t.Run("官方UA+带x-codex-头 → 放行", func(t *testing.T) { + c := newCodexDetectorTestContext(officialUA, "") + c.Request.Header.Set("x-codex-window-id", "a1") + got := det.Detect(c, acct, policy, nil) + require.True(t, got.Matched) + }) + t.Run("官方UA+无x-codex-头 → 拒(缺指纹)", func(t *testing.T) { + c := newCodexDetectorTestContext(officialUA, "") + c.Request.Header.Set("session-id", "u1") // 默认 session 未勾,不满足必须项 + got := det.Detect(c, acct, policy, nil) + require.False(t, got.Matched) + require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, got.Reason) + }) + t.Run("body通道: 勾body_path后 仅body命中 → 放行", func(t *testing.T) { + bodyPolicy := CodexRestrictionPolicy{ + EngineFingerprintSignals: []openai.EngineFingerprintSignal{ + {Type: openai.FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id"}, Required: true}, + }, + } + c := newCodexDetectorTestContext(officialUA, "") + got := det.Detect(c, acct, bodyPolicy, []byte(`{"client_metadata":{"x-codex-window-id":"c3"}}`)) + require.True(t, got.Matched) + }) +} + +func TestDetect_AccountAppServerToggle(t *testing.T) { + gin.SetMode(gin.TestMode) + d := NewOpenAICodexClientRestrictionDetector(nil) + acctOn := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true, "codex_cli_only_allow_app_server": true}} + } + acctOff := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{"codex_cli_only": true}} + } + withFP := func(ua, originator string) *gin.Context { + c := newCodexDetectorTestContext(ua, originator) + c.Request.Header.Set("x-codex-window-id", "1") + return c + } + defaultSignals := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} + + t.Run("账号 app-server ON + 引擎头 → 放行(全局 OFF 也放行)", func(t *testing.T) { + r := d.Detect(withFP("opencode/1.0", "opencode"), acctOn(), defaultSignals, nil) + require.True(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedAppServerClient, r.Reason) + }) + + t.Run("账号 app-server ON + 无引擎头 → 拒(缺指纹)", func(t *testing.T) { + r := d.Detect(newCodexDetectorTestContext("opencode/1.0", "opencode"), acctOn(), defaultSignals, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMissingEngineFingerprint, r.Reason) + }) + + t.Run("账号 app-server OFF + 全局 OFF → 拒(未命中)", func(t *testing.T) { + r := d.Detect(withFP("opencode/1.0", "opencode"), acctOff(), defaultSignals, nil) + require.False(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonNotMatchedUA, r.Reason) + }) + + t.Run("账号 app-server OFF + 全局 ON → 放行(OR)", func(t *testing.T) { + pol := CodexRestrictionPolicy{AllowAppServerClients: true, EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} + r := d.Detect(withFP("opencode/1.0", "opencode"), acctOff(), pol, nil) + require.True(t, r.Matched) + require.Equal(t, CodexClientRestrictionReasonMatchedAppServerClient, r.Reason) }) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 6035dc4ccd..10578a2380 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,7 +61,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - restrictionResult := s.detectCodexClientRestriction(c, account) + restrictionResult := s.detectCodexClientRestriction(c, account, body) logCodexCLIOnlyDetection(ctx, c, account, getAPIKeyIDFromContext(c), restrictionResult, body) if restrictionResult.Enabled && !restrictionResult.Matched { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 2758a3b0a1..1def695c91 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -916,18 +916,18 @@ func SnapshotOpenAICompatibilityFallbackMetrics() OpenAICompatibilityFallbackMet } } -func (s *OpenAIGatewayService) detectCodexClientRestriction(c *gin.Context, account *Account) CodexClientRestrictionDetectionResult { - var globalAllowedClients []string +func (s *OpenAIGatewayService) detectCodexClientRestriction(c *gin.Context, account *Account, body []byte) CodexClientRestrictionDetectionResult { + // 安全默认:即便缺 settingService(仅测试/误配可达)也保持指纹门为默认种子, + // 避免零值 policy(nil 信号)让指纹门失败开放。有 settingService 时整体覆盖为全局策略。 + policy := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} 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} - } + policy = s.settingService.GetCodexRestrictionPolicy(ctx) } - return s.getCodexClientRestrictionDetector().Detect(c, account, globalAllowedClients) + return s.getCodexClientRestrictionDetector().Detect(c, account, policy, body) } func getAPIKeyIDFromContext(c *gin.Context) int64 { @@ -2501,7 +2501,7 @@ func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, re func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { startTime := time.Now() - restrictionResult := s.detectCodexClientRestriction(c, account) + restrictionResult := s.detectCodexClientRestriction(c, account, body) apiKeyID := getAPIKeyIDFromContext(c) logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body) if restrictionResult.Enabled && !restrictionResult.Matched { 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 e245e89c0c..9e2ede0cba 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, _ []string) CodexClientRestrictionDetectionResult { +func (s *stubCodexRestrictionDetector) Detect(_ *gin.Context, _ *Account, _ CodexRestrictionPolicy, _ []byte) 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, nil) + result := got.Detect(c, account, CodexRestrictionPolicy{}, nil) require.True(t, result.Enabled) require.True(t, result.Matched) require.Equal(t, CodexClientRestrictionReasonForceCodexCLI, result.Reason) diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 0b6c16130b..8d326b7637 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -1024,7 +1024,8 @@ func TestOpenAIGatewayService_CodexCLIOnly_AllowOfficialClientFamilies(t *testin {name: "codex_cli_rs", ua: "codex_cli_rs/0.99.0", originator: ""}, {name: "codex_vscode", ua: "codex_vscode/1.0.0", originator: ""}, {name: "codex_app", ua: "codex_app/2.1.0", originator: ""}, - {name: "originator_codex_chatgpt_desktop", ua: "curl/8.0", originator: "codex_chatgpt_desktop"}, + // req②:codex_cli_only 下 UA 须能解析出引擎版本;originator 命中路径用可解析的非官方前缀 UA。 + {name: "originator_codex_chatgpt_desktop", ua: "myterm/0.141.0", originator: "codex_chatgpt_desktop"}, } for _, tt := range tests { @@ -1036,6 +1037,10 @@ func TestOpenAIGatewayService_CodexCLIOnly_AllowOfficialClientFamilies(t *testin if tt.originator != "" { c.Request.Header.Set("originator", tt.originator) } + // 引擎指纹头:真实官方客户端必带。本测试用 nil settingService 构造 gateway, + // detectCodexClientRestriction 会兜底默认种子指纹信号(只勾 x-codex-),与生产默认策略一致, + // 故官方家族也须携带 x-codex-* 才能过门(对齐 TestDetect_EngineFingerprintSignals)。 + c.Request.Header.Set("x-codex-window-id", "1") inputBody := []byte(`{"model":"gpt-5.2","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 2134f6380b..81415d457f 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) @@ -149,17 +150,16 @@ 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 +const codexRestrictionPolicyCacheTTL = 60 * time.Second +const codexRestrictionPolicyDBTimeout = 5 * time.Second + +// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。 +// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。 +type cachedCodexRestrictionPolicy struct { + value CodexRestrictionPolicy expiresAt int64 // unix nano } -const openAIAllowCodexPluginCacheTTL = 60 * time.Second -const openAIAllowCodexPluginErrorTTL = 5 * time.Second -const openAIAllowCodexPluginDBTimeout = 5 * time.Second - // cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。 // GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。 type cachedCyberSessionBlockRuntime struct { @@ -200,8 +200,8 @@ type SettingService struct { antigravityUAVersionSF singleflight.Group openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent openAICodexUASF singleflight.Group - openAIAllowCodexPluginCache atomic.Value // *cachedOpenAIAllowCodexPlugin - openAIAllowCodexPluginSF singleflight.Group + codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy + codexRestrictionPolicySF singleflight.Group cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime cyberSessionBlockRuntimeSF singleflight.Group @@ -711,7 +711,7 @@ func (s *SettingService) GetFrontendURL(ctx context.Context) string { } // GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s, -// 模式对齐 IsOpenAIAllowClaudeCodeCodexPluginEnabled(热路径零 DB 往返)。 +// 供网关热路径读取时避免 DB 往返。 // 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。 // 默认值:开关 false,TTL 1h(与粘性会话对齐)。 func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) { @@ -1136,52 +1136,262 @@ 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 { +var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ + Originator: "Claude Code", + UAContains: []string{"Claude Code/"}, +} + +// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code +// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the +// same originator + UA marker pair, so runtime checks no longer need a separate flag. +func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return nil + } + return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err) + } + if strings.TrimSpace(legacyValue) != "true" { + return nil + } + + rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + + var entries []openai.AllowedClientEntry + if strings.TrimSpace(rawWhitelist) != "" { + if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil { + return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + } + if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) { + return nil + } + + entries = append(entries, legacyClaudeCodeCodexWhitelistEntry) + encoded, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err) + } + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + return nil +} + +// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint +// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子, +// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。 +func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error { + if s == nil || s.settingRepo == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) + defer cancel() + + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" { + return nil // 已配置/已迁移 + } else if err != nil && !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + + bodyOn := false + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil { + bodyOn = strings.TrimSpace(v) == "true" + } else if !errors.Is(err, ErrSettingNotFound) { + return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err) + } + + seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals)) + copy(seed, openai.DefaultEngineFingerprintSignals) + if bodyOn { + for i := range seed { + if seed[i].Type == openai.FingerprintSignalBodyPath { + seed[i].Required = true + } + } + } + encoded, err := json.Marshal(seed) + if err != nil { + return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil { + return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err) + } + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) + return nil +} + +func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool { + wantOriginator := strings.TrimSpace(want.Originator) + if wantOriginator == "" { + return false + } + wantMarkers := normalizedCodexClientMarkers(want.UAContains) + if len(wantMarkers) == 0 { + return false + } + for _, entry := range entries { + if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) { + continue + } + gotMarkers := normalizedCodexClientMarkers(entry.UAContains) + if len(gotMarkers) != len(wantMarkers) { + continue + } + matched := true + for marker := range wantMarkers { + if _, ok := gotMarkers[marker]; !ok { + matched = false + break + } + } + if matched { + return true + } + } + return false +} + +func normalizedCodexClientMarkers(markers []string) map[string]struct{} { + normalized := make(map[string]struct{}, len(markers)) + for _, marker := range markers { + marker = strings.TrimSpace(marker) + if marker == "" { + continue + } + normalized[strings.ToLower(marker)] = struct{}{} + } + return normalized +} + +// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。 +// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。 +// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。 +func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy { + if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); 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 { + result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) { + if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { return cached.value, nil } } - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAllowCodexPluginDBTimeout) + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout) 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 + + pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号 + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil { + pol.MinCodexVersion = strings.TrimSpace(v) } - enabled := value == "true" - s.openAIAllowCodexPluginCache.Store(&cachedOpenAIAllowCodexPlugin{ - value: enabled, - expiresAt: time.Now().Add(openAIAllowCodexPluginCacheTTL).UnixNano(), + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil { + pol.MaxCodexVersion = strings.TrimSpace(v) + } + if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil { + pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启 + } + pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx) + pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist) + pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist) + + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{ + value: pol, + expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(), }) - return enabled, nil + return pol, nil }) - if val, ok := result.(bool); ok { - return val + if pol, ok := result.(CodexRestrictionPolicy); ok { + return pol } - return false + return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} +} + +// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。 +func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry { + v, err := s.settingRepo.GetValue(ctx, key) + if err != nil || strings.TrimSpace(v) == "" { + return nil + } + var entries []openai.AllowedClientEntry + if json.Unmarshal([]byte(v), &entries) != nil { + return nil + } + return entries +} + +// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。 +func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal { + v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals) + if err != nil || strings.TrimSpace(v) == "" { + return openai.DefaultEngineFingerprintSignals + } + sigs, ok := openai.ParseEngineFingerprintSignals(v) + if !ok { + return openai.DefaultEngineFingerprintSignals + } + return sigs +} + +// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义): +// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny, +// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。 +func ValidateCodexClientEntriesJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var entries []openai.AllowedClientEntry + if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") + } + return nil +} + +// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求 +// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND: +// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈, +// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。 +func ValidateCodexWhitelistEntriesJSON(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var entries []openai.AllowedClientEntry + if err := json.Unmarshal([]byte(trimmed), &entries); err != nil { + return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}") + } + for i, e := range entries { + if !e.IsWhitelistable() { + return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i) + } + } + return nil +} + +// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。 +func ValidateEngineFingerprintSignalsJSON(raw string) error { + return openai.ValidateEngineFingerprintSignalsJSON(raw) } // SetOnUpdateCallback sets a callback function to be called when settings are updated @@ -1999,7 +2209,13 @@ 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) + // codex_cli_only 加固 + updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) + updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) + updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist) + updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist) + updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients) + updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals) updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) @@ -2168,11 +2384,9 @@ 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(), - }) + // codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。 + s.codexRestrictionPolicySF.Forget("codex_restriction_policy") + s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0}) if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } @@ -2947,6 +3161,14 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", + // codex_cli_only 加固(默认:版本不检查、名单空、默认种子指纹信号) + SettingKeyMinCodexVersion: "", + SettingKeyMaxCodexVersion: "", + SettingKeyCodexCLIOnlyBlacklist: "", + SettingKeyCodexCLIOnlyWhitelist: "", + SettingKeyCodexCLIOnlyAllowAppServerClients: "false", + SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(), + // 分组隔离(默认不允许未分组 Key 调度) SettingKeyAllowUngroupedKeyScheduling: "false", SettingKeyEnableAnthropicCacheTTL1hInjection: "false", @@ -3491,7 +3713,17 @@ 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" + // codex_cli_only 加固 + result.MinCodexVersion = settings[SettingKeyMinCodexVersion] + result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] + result.CodexCLIOnlyBlacklist = settings[SettingKeyCodexCLIOnlyBlacklist] + result.CodexCLIOnlyWhitelist = settings[SettingKeyCodexCLIOnlyWhitelist] + result.CodexCLIOnlyAllowAppServerClients = settings[SettingKeyCodexCLIOnlyAllowAppServerClients] == "true" + if raw := strings.TrimSpace(settings[SettingKeyCodexCLIOnlyEngineFingerprintSignals]); raw != "" { + result.CodexCLIOnlyEngineFingerprintSignals = raw + } else { + result.CodexCLIOnlyEngineFingerprintSignals = openai.DefaultEngineFingerprintSignalsJSON() // 缺失/空 → 展示默认种子 + } // Web search emulation: quick enabled check from the JSON config if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" { diff --git a/backend/internal/service/setting_service_codex_policy_test.go b/backend/internal/service/setting_service_codex_policy_test.go new file mode 100644 index 0000000000..1c17b91ef8 --- /dev/null +++ b/backend/internal/service/setting_service_codex_policy_test.go @@ -0,0 +1,204 @@ +package service + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/stretchr/testify/require" +) + +func TestGetCodexRestrictionPolicy(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyMinCodexVersion: "0.141.0", + SettingKeyMaxCodexVersion: "0.200.0", + SettingKeyCodexCLIOnlyWhitelist: `[{"originator":"opencode","ua_contains":["opencode/"]}]`, + SettingKeyCodexCLIOnlyBlacklist: `[{"originator":"evil"}]`, + }}, &config.Config{}) + + pol := svc.GetCodexRestrictionPolicy(context.Background()) + require.Equal(t, "0.141.0", pol.MinCodexVersion) + require.Equal(t, "0.200.0", pol.MaxCodexVersion) + require.Len(t, pol.Whitelist, 1) + require.Equal(t, "opencode", pol.Whitelist[0].Originator) + require.Equal(t, []string{"opencode/"}, pol.Whitelist[0].UAContains) + require.Len(t, pol.Blacklist, 1) + require.Equal(t, "evil", pol.Blacklist[0].Originator) +} + +func TestGetCodexRestrictionPolicy_DefaultsSafe(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{}}, &config.Config{}) + + pol := svc.GetCodexRestrictionPolicy(context.Background()) + require.Empty(t, pol.MinCodexVersion) + require.Empty(t, pol.Whitelist) + require.Empty(t, pol.Blacklist) +} + +func TestGetCodexRestrictionPolicy_InvalidJSONSafe(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyWhitelist: "not-json", + SettingKeyCodexCLIOnlyBlacklist: "{bad", + }}, &config.Config{}) + + pol := svc.GetCodexRestrictionPolicy(context.Background()) + require.Empty(t, pol.Whitelist, "非法 JSON → 安全空名单") + require.Empty(t, pol.Blacklist, "非法 JSON → 安全空名单") +} + +type codexPolicyMigrationRepoStub struct { + values map[string]string + sets map[string]string +} + +func (s *codexPolicyMigrationRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unused") +} +func (s *codexPolicyMigrationRepoStub) GetValue(ctx context.Context, key string) (string, error) { + if v, ok := s.values[key]; ok { + return v, nil + } + return "", ErrSettingNotFound +} +func (s *codexPolicyMigrationRepoStub) Set(ctx context.Context, key, value string) error { + if s.sets == nil { + s.sets = map[string]string{} + } + s.sets[key] = value + s.values[key] = value + return nil +} +func (s *codexPolicyMigrationRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + panic("unused") +} +func (s *codexPolicyMigrationRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unused") +} +func (s *codexPolicyMigrationRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + panic("unused") +} +func (s *codexPolicyMigrationRepoStub) Delete(ctx context.Context, key string) error { + panic("unused") +} + +func TestMigrateOpenAIAllowClaudeCodeCodexPluginSetting(t *testing.T) { + t.Run("legacy true appends Claude Code entry to whitelist", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyOpenAIAllowClaudeCodeCodexPlugin: "true", + SettingKeyCodexCLIOnlyWhitelist: `[{"originator":"opencode","ua_contains":["opencode/"]}]`, + }} + svc := NewSettingService(repo, &config.Config{}) + + require.NoError(t, svc.MigrateOpenAIAllowClaudeCodeCodexPluginSetting(context.Background())) + + raw := repo.sets[SettingKeyCodexCLIOnlyWhitelist] + require.NotEmpty(t, raw) + var entries []struct { + Originator string `json:"originator"` + UAContains []string `json:"ua_contains"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &entries)) + require.Len(t, entries, 2) + require.Equal(t, "opencode", entries[0].Originator) + require.Equal(t, "Claude Code", entries[1].Originator) + require.Equal(t, []string{"Claude Code/"}, entries[1].UAContains) + }) + + t.Run("legacy true does not duplicate existing Claude Code entry", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyOpenAIAllowClaudeCodeCodexPlugin: "true", + SettingKeyCodexCLIOnlyWhitelist: `[{"originator":"Claude Code","ua_contains":["Claude Code/"]}]`, + }} + svc := NewSettingService(repo, &config.Config{}) + + require.NoError(t, svc.MigrateOpenAIAllowClaudeCodeCodexPluginSetting(context.Background())) + + _, wrote := repo.sets[SettingKeyCodexCLIOnlyWhitelist] + require.False(t, wrote) + }) +} + +func TestGetCodexRestrictionPolicy_AllowAppServerClients(t *testing.T) { + t.Run("显式 true 开启", func(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyAllowAppServerClients: "true", + }}, &config.Config{}) + require.True(t, svc.GetCodexRestrictionPolicy(context.Background()).AllowAppServerClients) + }) + t.Run("缺失默认 false", func(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{}}, &config.Config{}) + require.False(t, svc.GetCodexRestrictionPolicy(context.Background()).AllowAppServerClients) + }) + t.Run("非 true 值视为 false", func(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyAllowAppServerClients: "1", + }}, &config.Config{}) + require.False(t, svc.GetCodexRestrictionPolicy(context.Background()).AllowAppServerClients) + }) +} + +func TestGetCodexRestrictionPolicy_EngineFingerprintSignals(t *testing.T) { + t.Run("键缺失 → 默认种子(只勾x-codex-)", func(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{}}, &config.Config{}) + pol := svc.GetCodexRestrictionPolicy(context.Background()) + require.True(t, len(pol.EngineFingerprintSignals) > 0) + require.True(t, openaiEngineSignalsEqual(pol.EngineFingerprintSignals, openai.DefaultEngineFingerprintSignals)) + }) + t.Run("显式配置 → 原样采用", func(t *testing.T) { + raw := `[{"type":"header_exact","match":["session-id"],"required":true}]` + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyEngineFingerprintSignals: raw, + }}, &config.Config{}) + pol := svc.GetCodexRestrictionPolicy(context.Background()) + require.Len(t, pol.EngineFingerprintSignals, 1) + require.Equal(t, "session-id", pol.EngineFingerprintSignals[0].Match[0]) + }) + t.Run("非法JSON → 回落默认种子", func(t *testing.T) { + svc := NewSettingService(&codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyEngineFingerprintSignals: "not json", + }}, &config.Config{}) + pol := svc.GetCodexRestrictionPolicy(context.Background()) + require.True(t, openaiEngineSignalsEqual(pol.EngineFingerprintSignals, openai.DefaultEngineFingerprintSignals)) + }) +} + +func openaiEngineSignalsEqual(a, b []openai.EngineFingerprintSignal) bool { + return reflect.DeepEqual(a, b) +} + +func TestMigrateCodexBodyFingerprintToSignals(t *testing.T) { + t.Run("信号键已存在 → 不动", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyEngineFingerprintSignals: `[{"type":"header_exact","match":["session-id"],"required":true}]`, + SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint: "true", + }} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateCodexBodyFingerprintToSignals(context.Background())) + require.Equal(t, `[{"type":"header_exact","match":["session-id"],"required":true}]`, repo.values[SettingKeyCodexCLIOnlyEngineFingerprintSignals]) + }) + t.Run("信号键缺失 + 旧body=true → 写种子且body行Required=true", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{ + SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint: "true", + }} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateCodexBodyFingerprintToSignals(context.Background())) + sigs, ok := openai.ParseEngineFingerprintSignals(repo.values[SettingKeyCodexCLIOnlyEngineFingerprintSignals]) + require.True(t, ok) + var bodyReq bool + for _, s := range sigs { + if s.Type == openai.FingerprintSignalBodyPath { + bodyReq = s.Required + } + } + require.True(t, bodyReq) + }) + t.Run("信号键缺失 + 旧body=false/缺 → 写种子(body不勾)", func(t *testing.T) { + repo := &codexPolicyMigrationRepoStub{values: map[string]string{}} + svc := NewSettingService(repo, &config.Config{}) + require.NoError(t, svc.MigrateCodexBodyFingerprintToSignals(context.Background())) + require.Equal(t, openai.DefaultEngineFingerprintSignalsJSON(), repo.values[SettingKeyCodexCLIOnlyEngineFingerprintSignals]) + }) +} diff --git a/backend/internal/service/setting_service_codex_validate_test.go b/backend/internal/service/setting_service_codex_validate_test.go new file mode 100644 index 0000000000..f0b42bb69b --- /dev/null +++ b/backend/internal/service/setting_service_codex_validate_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateCodexClientEntriesJSON(t *testing.T) { + require.NoError(t, ValidateCodexClientEntriesJSON(""), "空=合法(禁用)") + require.NoError(t, ValidateCodexClientEntriesJSON(" "), "空白=合法") + require.NoError(t, ValidateCodexClientEntriesJSON(`[]`), "空数组合法") + require.NoError(t, ValidateCodexClientEntriesJSON(`[{"originator":"opencode","ua_contains":["opencode/"]}]`), "合法条目") + require.NoError(t, ValidateCodexClientEntriesJSON(`[{"originator":"evil"}]`), "仅 originator 合法") + + require.Error(t, ValidateCodexClientEntriesJSON("not-json"), "非 JSON 应报错") + require.Error(t, ValidateCodexClientEntriesJSON(`{"originator":"x"}`), "对象非数组应报错") + require.Error(t, ValidateCodexClientEntriesJSON(`[1,2,3]`), "非对象数组应报错") +} + +func TestValidateCodexWhitelistEntriesJSON(t *testing.T) { + require.NoError(t, ValidateCodexWhitelistEntriesJSON(""), "空=合法(禁用)") + require.NoError(t, ValidateCodexWhitelistEntriesJSON(" "), "空白=合法") + require.NoError(t, ValidateCodexWhitelistEntriesJSON(`[]`), "空数组合法") + require.NoError(t, ValidateCodexWhitelistEntriesJSON(`[{"originator":"opencode","ua_contains":["opencode/"]}]`), "完整条目合法") + + // 白名单专属(双因子 AND):会静默失效的条目应在写入时即报错 + require.Error(t, ValidateCodexWhitelistEntriesJSON(`[{"originator":"evil"}]`), "仅 originator(白名单会静默失效)应报错") + require.Error(t, ValidateCodexWhitelistEntriesJSON(`[{"originator":"x","ua_contains":[]}]`), "空 ua_contains 应报错") + require.Error(t, ValidateCodexWhitelistEntriesJSON(`[{"originator":"x","ua_contains":["a/",""]}]`), "含空白 marker 应报错") + require.Error(t, ValidateCodexWhitelistEntriesJSON(`[{"ua_contains":["a/"]}]`), "缺 originator 应报错") + + // 结构错误沿用基础校验 + require.Error(t, ValidateCodexWhitelistEntriesJSON("not-json"), "非 JSON 应报错") + require.Error(t, ValidateCodexWhitelistEntriesJSON(`{"originator":"x"}`), "对象非数组应报错") +} + +func TestValidateEngineFingerprintSignalsJSON_ServiceWrapper(t *testing.T) { + require.NoError(t, ValidateEngineFingerprintSignalsJSON("")) + require.NoError(t, ValidateEngineFingerprintSignalsJSON(`[{"type":"header_prefix","match":["x-codex-"],"required":true}]`)) + require.Error(t, ValidateEngineFingerprintSignalsJSON(`[{"type":"bogus","match":["x"]}]`)) +} 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 deleted file mode 100644 index 22059f07bb..0000000000 --- a/backend/internal/service/setting_service_openai_allow_claude_code_test.go +++ /dev/null @@ -1,55 +0,0 @@ -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 932647dd54..17d2d741ca 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -200,7 +200,12 @@ 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) + MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查 + MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查 + CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny) + CodexCLIOnlyWhitelist string // codex_cli_only 全局白名单 JSON([]AllowedClientEntry,AND allow) + CodexCLIOnlyAllowAppServerClients bool // codex_cli_only App Server 开关:对未列名客户端开闸(默认 false) + CodexCLIOnlyEngineFingerprintSignals string // codex_cli_only 引擎指纹门信号列表 JSON([]EngineFingerprintSignal) // Web Search Emulation WebSearchEmulationEnabled bool // 是否启用 web search 模拟 diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index b3b1170d97..7278a4e0c5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -503,6 +503,12 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit if err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()); err != nil { logger.LegacyPrintf("service.setting", "Warning: load api key acl forwarded ip setting failed: %v", err) } + if err := svc.MigrateOpenAIAllowClaudeCodeCodexPluginSetting(context.Background()); err != nil { + logger.LegacyPrintf("service.setting", "Warning: migrate openai allow Claude Code Codex plugin setting failed: %v", err) + } + if err := svc.MigrateCodexBodyFingerprintToSignals(context.Background()); err != nil { + logger.LegacyPrintf("service.setting", "Warning: migrate codex body fingerprint to signals failed: %v", err) + } antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion) return svc } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index bc3f3fa4df..9210e8c84f 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -563,7 +563,13 @@ 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; + // codex_cli_only 加固 + min_codex_version: string; + max_codex_version: string; + codex_cli_only_blacklist: string; + codex_cli_only_whitelist: string; + codex_cli_only_allow_app_server_clients: boolean; + codex_cli_only_engine_fingerprint_signals: string; web_search_emulation_enabled?: boolean; // Payment configuration @@ -807,7 +813,13 @@ 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; + // codex_cli_only 加固 + min_codex_version?: string; + max_codex_version?: string; + codex_cli_only_blacklist?: string; + codex_cli_only_whitelist?: string; + codex_cli_only_allow_app_server_clients?: boolean; + codex_cli_only_engine_fingerprint_signals?: string; // 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 6e71fe4bbd..c062befebe 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -742,44 +742,44 @@ - +

- {{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }} + {{ t('admin.accounts.openai.codexCLIOnlyAppServerDesc') }}

@@ -1263,7 +1263,7 @@ const enableOpenAIPassthrough = ref(false) const enableOpenAIWSMode = ref(false) const enableOpenAIAPIKeyWSMode = ref(false) const enableCodexCLIOnly = ref(false) -const enableCodexCLIOnlyAllowClaudeCode = ref(false) +const enableCodexCLIOnlyAppServer = ref(false) const enableOpenAICompactMode = ref(false) const enableOpenAICompactModelMapping = ref(false) const enableRpmLimit = ref(false) @@ -1291,7 +1291,7 @@ const openaiPassthroughEnabled = ref(false) const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) -const codexCLIOnlyAllowClaudeCodeEnabled = ref(false) +const codexCLIOnlyAppServerEnabled = ref(false) const openAICompactMode = ref('auto') const openAICompactModelMappings = ref([]) const rpmLimitEnabled = ref(false) @@ -1542,9 +1542,15 @@ const buildUpdatePayload = (): Record | null => { extra.codex_cli_only = codexCLIOnlyEnabled.value } - if (enableCodexCLIOnlyAllowClaudeCode.value) { + // 子开关从属于 codex_cli_only:仅当同一次批量编辑也把父开关设为开启时才写入, + // 与 Create/Edit 语义对齐,避免在父开关关闭的账号上写入无意义的孤立字段。 + if ( + enableCodexCLIOnlyAppServer.value && + enableCodexCLIOnly.value && + codexCLIOnlyEnabled.value + ) { const extra = ensureExtra() - extra.codex_cli_only_allowed_clients = codexCLIOnlyAllowClaudeCodeEnabled.value ? ['claude_code'] : [] + extra.codex_cli_only_allow_app_server = codexCLIOnlyAppServerEnabled.value } if (enableOpenAICompactMode.value) { @@ -1653,7 +1659,7 @@ const handleSubmit = async () => { enableOpenAIWSMode.value || enableOpenAIAPIKeyWSMode.value || enableCodexCLIOnly.value || - enableCodexCLIOnlyAllowClaudeCode.value || + enableCodexCLIOnlyAppServer.value || enableOpenAICompactMode.value || enableOpenAICompactModelMapping.value || enableRpmLimit.value || @@ -1756,7 +1762,7 @@ watch( enableOpenAIWSMode.value = false enableOpenAIAPIKeyWSMode.value = false enableCodexCLIOnly.value = false - enableCodexCLIOnlyAllowClaudeCode.value = false + enableCodexCLIOnlyAppServer.value = false enableOpenAICompactMode.value = false enableOpenAICompactModelMapping.value = false enableRpmLimit.value = false @@ -1780,7 +1786,7 @@ watch( openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false - codexCLIOnlyAllowClaudeCodeEnabled.value = false + codexCLIOnlyAppServerEnabled.value = false openAICompactMode.value = 'auto' openAICompactModelMappings.value = [] rpmLimitEnabled.value = false diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index a93a99c106..4df73c5309 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2700,23 +2700,23 @@ class="mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-4 dark:border-dark-600" >
- +

- {{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }} + {{ t('admin.accounts.openai.codexCLIOnlyAppServerDesc') }}

@@ -3496,7 +3496,7 @@ const openAIEndpointCapabilities = ref(['chat_comple const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) -const codexCLIOnlyAllowClaudeCodeEnabled = ref(false) +const codexCLIOnlyAppServerEnabled = ref(false) const anthropicPassthroughEnabled = ref(false) const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) @@ -3935,7 +3935,7 @@ watch( openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false - codexCLIOnlyAllowClaudeCodeEnabled.value = false + codexCLIOnlyAppServerEnabled.value = false } if (newPlatform !== 'anthropic') { anthropicPassthroughEnabled.value = false @@ -3957,7 +3957,7 @@ watch( ([category, platform]) => { if (platform === 'openai' && category !== 'oauth-based') { codexCLIOnlyEnabled.value = false - codexCLIOnlyAllowClaudeCodeEnabled.value = false + codexCLIOnlyAppServerEnabled.value = false } if (platform !== 'anthropic' || category !== 'apikey') { anthropicPassthroughEnabled.value = false @@ -4338,7 +4338,7 @@ const resetForm = () => { openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false - codexCLIOnlyAllowClaudeCodeEnabled.value = false + codexCLIOnlyAppServerEnabled.value = false anthropicPassthroughEnabled.value = false webSearchEmulationMode.value = 'default' // Reset quota control state @@ -4419,14 +4419,15 @@ const buildOpenAIExtra = (base?: Record): Record
- +

- {{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }} + {{ t('admin.accounts.openai.codexCLIOnlyAppServerDesc') }}

@@ -2603,7 +2603,7 @@ const openAIEndpointCapabilities = ref(['chat_comple const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) -const codexCLIOnlyAllowClaudeCodeEnabled = ref(false) +const codexCLIOnlyAppServerEnabled = ref(false) type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled' const codexImageGenerationBridgeMode = ref('inherit') const anthropicPassthroughEnabled = ref(false) @@ -2986,7 +2986,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false - codexCLIOnlyAllowClaudeCodeEnabled.value = false + codexCLIOnlyAppServerEnabled.value = false codexImageGenerationBridgeMode.value = 'inherit' anthropicPassthroughEnabled.value = false webSearchEmulationMode.value = 'default' @@ -3024,9 +3024,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { }) if (newAccount.type === 'oauth') { codexCLIOnlyEnabled.value = extra?.codex_cli_only === true - codexCLIOnlyAllowClaudeCodeEnabled.value = - Array.isArray(extra?.codex_cli_only_allowed_clients) && - (extra.codex_cli_only_allowed_clients as unknown[]).includes('claude_code') + codexCLIOnlyAppServerEnabled.value = + extra?.codex_cli_only_allow_app_server === true } const credentials = newAccount.credentials as Record | undefined const compactMappings = credentials?.compact_model_mapping as Record | undefined @@ -4169,11 +4168,12 @@ const handleSubmit = async () => { } else { delete newExtra.codex_cli_only } - // 仅当 codex_cli_only 开启且子开关开启时写入 Claude Code 插件白名单,否则清除避免孤立字段 - if (codexCLIOnlyEnabled.value && codexCLIOnlyAllowClaudeCodeEnabled.value) { - newExtra.codex_cli_only_allowed_clients = ['claude_code'] + // Claude Code 插件放行已迁移到全局 codex_cli_only_whitelist,编辑时清理废弃账号级快捷字段。 + delete newExtra.codex_cli_only_allowed_clients + if (codexCLIOnlyEnabled.value && codexCLIOnlyAppServerEnabled.value) { + newExtra.codex_cli_only_allow_app_server = true } else { - delete newExtra.codex_cli_only_allowed_clients + delete newExtra.codex_cli_only_allow_app_server } } diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 3ae75ee934..1ccf8ed363 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -197,25 +197,44 @@ describe('BulkEditAccountModal', () => { }) }) - it('OpenAI OAuth 批量编辑应提交 codex_cli_only_allowed_clients 字段', async () => { + it('OpenAI OAuth 批量编辑应提交 codex_cli_only_allow_app_server 字段(需同时开启父开关)', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['oauth'] }) - await wrapper.get('#bulk-edit-openai-codex-allow-claude-code-enabled').setValue(true) - await wrapper.get('#bulk-edit-openai-codex-allow-claude-code-toggle').trigger('click') + // 子开关从属于 codex_cli_only:必须同时批量开启父开关才写入 + await wrapper.get('#bulk-edit-openai-codex-cli-only-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-codex-cli-only-toggle').trigger('click') + await wrapper.get('#bulk-edit-openai-codex-app-server-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-codex-app-server-toggle').trigger('click') await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') await flushPromises() expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { extra: { - codex_cli_only_allowed_clients: ['claude_code'] + codex_cli_only: true, + codex_cli_only_allow_app_server: true } }) }) + it('未同时开启父开关时不应写入 codex_cli_only_allow_app_server', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + // 仅开启子开关、不批量设置父开关 codex_cli_only:不应写入孤立字段,也不应调用接口 + await wrapper.get('#bulk-edit-openai-codex-app-server-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-codex-app-server-toggle').trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).not.toHaveBeenCalled() + }) + it('OpenAI API Key 批量编辑应提交 API Key 专属 WS mode 字段', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index dba2070901..808cb36013 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3461,9 +3461,9 @@ export default { codexCLIOnly: 'Codex official clients only', codexCLIOnlyDesc: 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', - codexCLIOnlyAllowClaudeCode: "Also allow Claude Code's Codex plugin", - codexCLIOnlyAllowClaudeCodeDesc: - 'Only takes effect when the switch above is on. Additionally allows requests from the Claude Code Codex plugin (exact match on originator=Claude Code) without weakening blocking of other non-official clients.', + codexCLIOnlyAppServer: 'Allow Codex app-server clients', + codexCLIOnlyAppServerDesc: + "Effective only when the switch above is on. When enabled, this account also allows third-party clients that embed the Codex engine over the app-server protocol (e.g. Claude Code's codex plugin); they still pass the global engine-fingerprint gate. OR-combined with the global app-server toggle.", codexImageGenerationBridge: 'Codex image-generation bridge', codexImageGenerationBridgeDesc: 'Account policy takes precedence over channel and global settings. Only controls whether Codex requests through the /responses text endpoint receive the image_generation tool; standalone image-generation endpoints are unaffected.', @@ -5830,9 +5830,41 @@ export default { openaiCodexUserAgent: 'OpenAI Codex UA', openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default.', - openaiAllowClaudeCodeCodexPlugin: "Allow using the Codex plugin in Claude Code", - openaiAllowClaudeCodeCodexPluginDesc: - "Global switch; only affects OpenAI OAuth accounts that have 'Codex official clients only' enabled. When on, all such accounts additionally allow requests from the Claude Code Codex plugin (exact match on originator=Claude Code) without per-account config; upstream requests remain pass-through.", + codexHardeningTitle: "Codex Settings", + codexClientRestrictionTitle: "Codex client restriction", + codexHardeningDesc: + "Only affects OpenAI OAuth accounts with 'Codex official clients only' enabled (global). Beyond User-Agent/Originator, harden the decision with a version range, an engine-fingerprint gate, and black/whitelists.", + minCodexVersion: "Min Codex Version", + minCodexVersionPlaceholder: "e.g. 0.142.0", + maxCodexVersion: "Max Codex Version", + maxCodexVersionPlaceholder: "e.g. 0.200.0", + codexVersionHint: + "Official clients only: checks their version against the [min, max] range. Leave a side empty to not limit it.", + codexFingerprintSignals: "Codex engine fingerprint signals", + codexFingerprintSignalsDesc: + "Define engine-fingerprint signals: every Required signal must match (AND); within a row, '/'-separated variants are OR'd. None checked = not enforced. Default checks only the x-codex- prefix. Types: header exact / header prefix / body path.", + codexFpTypeHeaderExact: "Header exact", + codexFpTypeHeaderPrefix: "Header prefix", + codexFpTypeBodyPath: "Body path", + codexFpMatchPlaceholder: "match; '/'-separate variants (e.g. session-id / session_id or x-codex-)", + codexFpRequired: "Required", + codexFingerprintNoRequiredWarn: "No signal is marked Required — the engine-fingerprint gate is inactive, allowing every candidate that passes identity/version. Check at least one signal to enable it.", + codexAllowAppServer: "Codex app-server", + codexAllowAppServerDesc: + "Allow third-party clients that embed the Codex engine and connect over the app-server protocol (e.g. Claude Code's codex plugin). Off by default; when on, such clients are allowed once they pass the engine-fingerprint gate (the signal list below); off = only official clients and the whitelist are allowed.", + codexBlacklist: "User-Agent/Originator Blacklist", + codexBlacklistDesc: + "Deny if any field matches; takes precedence over any allow. originator is exact; User-Agent is a 'contains' match (comma-separated).", + codexWhitelist: "User-Agent/Originator Whitelist", + codexWhitelistDesc: + "Allow clients outside the official set: requires exact originator and every User-Agent marker present. Still subject to the fingerprint gate unless 'Skip engine fingerprint' is checked.", + codexWhitelistSkipFingerprint: "Skip engine fingerprint", + codexWhitelistSkipFingerprintTooltip: + "Risk: when checked this entry is allowed on originator + User-Agent alone (both forgeable), with no engine-fingerprint backstop. Use only for trusted third-party clients that genuinely do not send a codex engine fingerprint.", + codexOriginatorPlaceholder: "originator (exact, e.g. opencode)", + codexUaContainsPlaceholder: "User-Agent contains markers, comma-separated (e.g. opencode/)", + codexAddRow: "Add entry", + codexRemoveRow: "Remove", }, webSearchEmulation: { title: 'Web Search Emulation', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index d4af2069ce..8413804e2c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3630,8 +3630,8 @@ export default { responsesStatusForcedChatCompletions: '已强制 Chat Completions', codexCLIOnly: '仅允许 Codex 官方客户端', codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', - codexCLIOnlyAllowClaudeCode: '额外放行 Claude Code 的 Codex 插件', - codexCLIOnlyAllowClaudeCodeDesc: '仅在上方开关开启时生效。额外放行通过 Claude Code 的 Codex 插件发起的请求(精确匹配 originator=Claude Code),不影响对其他非官方客户端的拦截。', + codexCLIOnlyAppServer: '允许 Codex app-server 客户端', + codexCLIOnlyAppServerDesc: '仅在上方开关开启时生效。开启后本账号额外放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件),仍需通过全局引擎指纹门;与全局 app-server 开关取 OR(任一开即放行)。', codexImageGenerationBridge: 'Codex 图片生成桥接', codexImageGenerationBridgeDesc: '账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。', @@ -5984,9 +5984,41 @@ export default { openaiCodexUserAgent: 'OpenAI Codex UA', openaiCodexUserAgentPlaceholder: 'codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)', openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。', - openaiAllowClaudeCodeCodexPlugin: '允许在 Claude Code 中使用 Codex 插件', - openaiAllowClaudeCodeCodexPluginDesc: - '全局开关,仅对已开启「仅允许 Codex 官方客户端」的 OpenAI OAuth 账号生效。开启后,所有此类账号都额外放行通过 Claude Code 的 Codex 插件发起的请求(精确匹配 originator=Claude Code),无需逐账号配置;上游请求仍保持透传。', + codexHardeningTitle: 'Codex 设置', + codexClientRestrictionTitle: 'Codex 客户端限制', + codexHardeningDesc: + '仅对已开启「仅允许 Codex 官方客户端」的 OpenAI OAuth 账号生效(全局)。在 User-Agent/Originator 之外,用版本区间、引擎指纹门与黑/白名单巩固判定。', + minCodexVersion: '最低 Codex 版本', + minCodexVersionPlaceholder: '例如 0.142.0', + maxCodexVersion: '最高 Codex 版本', + maxCodexVersionPlaceholder: '例如 0.200.0', + codexVersionHint: + '仅对官方客户端生效,校验其版本是否落在 [最低, 最高] 区间。留空表示该侧不限制。', + codexFingerprintSignals: 'Codex 引擎指纹信号', + codexFingerprintSignalsDesc: + '定义引擎指纹信号:勾「必须」的信号需全部命中(AND),每条 / 分隔的变体取或(OR);一条都不勾即不校验。默认只勾 x-codex- 前缀。类型:头精确 / 头前缀 / body 路径。', + codexFpTypeHeaderExact: '头精确', + codexFpTypeHeaderPrefix: '头前缀', + codexFpTypeBodyPath: 'body 路径', + codexFpMatchPlaceholder: '匹配,变体用 / 分隔(如 session-id / session_id 或 x-codex-)', + codexFpRequired: '必须', + codexFingerprintNoRequiredWarn: '未勾选任何「必须」信号——引擎指纹门当前不生效,等于放行所有通过身份/版本的候选。如需启用校验,请至少勾选一条信号。', + codexAllowAppServer: 'Codex app-server', + codexAllowAppServerDesc: + '放行内嵌 Codex 引擎、经 app-server 协议接入的第三方客户端(如 Claude Code 的 codex 插件)。默认关闭;开启后此类客户端通过引擎指纹门(下方信号列表)即放行,关闭则仅放行官方客户端与白名单。', + codexBlacklist: 'User-Agent/Originator 黑名单', + codexBlacklistDesc: + '命中任一字段即拒,优先于一切放行。originator 精确匹配,User-Agent 为包含匹配(多个用逗号分隔)。', + codexWhitelist: 'User-Agent/Originator 白名单', + codexWhitelistDesc: + '放行官方集之外的客户端:需 originator 精确,且每个 User-Agent 标记都命中。默认仍需过引擎指纹门,勾「跳过引擎指纹」可免。', + codexWhitelistSkipFingerprint: '跳过引擎指纹', + codexWhitelistSkipFingerprintTooltip: + '风险:勾选后该条仅凭 originator + User-Agent(均可伪造)放行,不再要求引擎指纹兜底。仅用于确属可信、但本身不发 codex 引擎指纹的第三方客户端。', + codexOriginatorPlaceholder: 'originator(精确,如 opencode)', + codexUaContainsPlaceholder: 'User-Agent 包含标记,逗号分隔(如 opencode/)', + codexAddRow: '添加一条', + codexRemoveRow: '删除', }, webSearchEmulation: { title: 'Web Search 模拟', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index bb63765719..b4b83244b5 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3724,6 +3724,254 @@
+ +
+
+

+ {{ t("admin.settings.gatewayForwarding.codexHardeningTitle") }} +

+
+
+
+

+ {{ t("admin.settings.gatewayForwarding.codexClientRestrictionTitle") }} +

+

+ {{ t("admin.settings.gatewayForwarding.codexHardeningDesc") }} +

+
+
+
+ + +
+
+ + +
+
+

+ {{ t("admin.settings.gatewayForwarding.codexVersionHint") }} +

+ +
+ +

+ {{ t("admin.settings.gatewayForwarding.codexFingerprintSignalsDesc") }} +

+
+ + + + +
+ +

+ {{ t("admin.settings.gatewayForwarding.codexFingerprintNoRequiredWarn") }} +

+
+ +
+
+ +

+ {{ + t( + "admin.settings.gatewayForwarding.codexAllowAppServerDesc", + ) + }} +

+
+ +
+ +
+ +

+ {{ t("admin.settings.gatewayForwarding.codexBlacklistDesc") }} +

+
+ + + +
+ +
+ +
+ +

+ {{ t("admin.settings.gatewayForwarding.codexWhitelistDesc") }} +

+
+ + + + +
+ +
+
+
+
- -
-
- -

- {{ t("admin.settings.gatewayForwarding.openaiAllowClaudeCodeCodexPluginDesc") }} -

-
- -
+
({ rewrite_message_cache_control: false, antigravity_user_agent_version: "", openai_codex_user_agent: "", - openai_allow_claude_code_codex_plugin: false, + // codex_cli_only 加固 + min_codex_version: "", + max_codex_version: "", + codex_cli_only_blacklist: "", + codex_cli_only_whitelist: "", + codex_cli_only_allow_app_server_clients: false, + codex_cli_only_engine_fingerprint_signals: "", // 余额、订阅到期与账号限额通知 balance_low_notify_enabled: false, balance_low_notify_threshold: 0, @@ -8443,6 +8692,82 @@ function parseTablePageSizeOptionsInput(raw: string): number[] | null { return deduped; } +// ── codex_cli_only 黑/白名单结构化编辑(行 ↔ JSON)── +interface CodexClientRow { + originator: string; + uaContains: string; // 逗号分隔,序列化时拆成 ua_contains 数组 + skipEngineFingerprint?: boolean; // 仅白名单:命中即跳过引擎指纹门 +} +const codexBlacklistRows = ref([]); +const codexWhitelistRows = ref([]); +const codexFingerprintRows = ref([]); +const codexFingerprintNoRequired = computed( + () => !codexFingerprintRows.value.some((r) => r.required), +); +function addCodexFingerprintRow(): void { + codexFingerprintRows.value.push({ type: "header_exact", match: "", required: false }); +} +function removeCodexFingerprintRow(i: number): void { + codexFingerprintRows.value.splice(i, 1); +} + +function parseCodexEntriesToRows(raw: string): CodexClientRow[] { + if (!raw || !raw.trim()) return []; + try { + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return []; + return arr.map((e) => ({ + originator: typeof e?.originator === "string" ? e.originator : "", + uaContains: Array.isArray(e?.ua_contains) + ? e.ua_contains + .filter((x: unknown) => typeof x === "string") + .join(", ") + : "", + skipEngineFingerprint: e?.skip_engine_fingerprint === true, + })); + } catch { + return []; + } +} + +function serializeCodexRowsToJSON(rows: CodexClientRow[]): string { + const entries = rows + .map((r) => { + const entry: { + originator: string; + ua_contains: string[]; + skip_engine_fingerprint?: boolean; + } = { + originator: r.originator.trim(), + ua_contains: r.uaContains + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + }; + if (r.skipEngineFingerprint) entry.skip_engine_fingerprint = true; + return entry; + }) + .filter((e) => e.originator !== "" || e.ua_contains.length > 0); + return entries.length > 0 ? JSON.stringify(entries) : ""; +} + +function addCodexBlacklistRow(): void { + codexBlacklistRows.value.push({ originator: "", uaContains: "" }); +} +function removeCodexBlacklistRow(i: number): void { + codexBlacklistRows.value.splice(i, 1); +} +function addCodexWhitelistRow(): void { + codexWhitelistRows.value.push({ + originator: "", + uaContains: "", + skipEngineFingerprint: false, + }); +} +function removeCodexWhitelistRow(i: number): void { + codexWhitelistRows.value.splice(i, 1); +} + async function loadSettings() { loading.value = true; loadFailed.value = false; @@ -8465,6 +8790,15 @@ async function loadSettings() { form.claude_oauth_system_prompt, ); syncClaudeOAuthSystemPromptBlocksFormField(); + codexBlacklistRows.value = parseCodexEntriesToRows( + form.codex_cli_only_blacklist, + ); + codexWhitelistRows.value = parseCodexEntriesToRows( + form.codex_cli_only_whitelist, + ); + codexFingerprintRows.value = form.codex_cli_only_engine_fingerprint_signals + ? parseFingerprintSignalsToRows(form.codex_cli_only_engine_fingerprint_signals) + : defaultFingerprintSignalRows(); form.login_agreement_mode = settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal"; form.login_agreement_updated_at = @@ -8966,7 +9300,19 @@ async function saveSettings() { form.antigravity_user_agent_version?.trim() || "", openai_codex_user_agent: form.openai_codex_user_agent?.trim() || "", - openai_allow_claude_code_codex_plugin: form.openai_allow_claude_code_codex_plugin, + min_codex_version: form.min_codex_version?.trim() || "", + max_codex_version: form.max_codex_version?.trim() || "", + codex_cli_only_allow_app_server_clients: + form.codex_cli_only_allow_app_server_clients, + codex_cli_only_engine_fingerprint_signals: serializeFingerprintRowsToJSON( + codexFingerprintRows.value, + ), + codex_cli_only_blacklist: serializeCodexRowsToJSON( + codexBlacklistRows.value, + ), + codex_cli_only_whitelist: serializeCodexRowsToJSON( + codexWhitelistRows.value, + ), // Payment configuration payment_enabled: form.payment_enabled, risk_control_enabled: form.risk_control_enabled, diff --git a/frontend/src/views/admin/__tests__/codexFingerprintSignals.spec.ts b/frontend/src/views/admin/__tests__/codexFingerprintSignals.spec.ts new file mode 100644 index 0000000000..35913e2b8c --- /dev/null +++ b/frontend/src/views/admin/__tests__/codexFingerprintSignals.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { + parseFingerprintSignalsToRows, + serializeFingerprintRowsToJSON, +} from "../codexFingerprintSignals"; + +describe("codex fingerprint signals 行编解码", () => { + it("解析: 变体数组 → / 合并字符串", () => { + const rows = parseFingerprintSignalsToRows( + '[{"type":"header_exact","match":["session-id","session_id"],"required":true}]', + ); + expect(rows).toEqual([ + { type: "header_exact", match: "session-id / session_id", required: true }, + ]); + }); + it("序列化: / 合并 → 变体数组, required 透传", () => { + const json = serializeFingerprintRowsToJSON([ + { type: "header_prefix", match: "x-codex-", required: true }, + { type: "body_path", match: " a / b ", required: false }, + ]); + expect(JSON.parse(json)).toEqual([ + { type: "header_prefix", match: ["x-codex-"], required: true }, + { type: "body_path", match: ["a", "b"], required: false }, + ]); + }); + it("空/非法 → 空数组 / [] 串", () => { + expect(parseFingerprintSignalsToRows("")).toEqual([]); + expect(parseFingerprintSignalsToRows("nope")).toEqual([]); + expect(serializeFingerprintRowsToJSON([])).toBe("[]"); + }); +}); diff --git a/frontend/src/views/admin/codexFingerprintSignals.ts b/frontend/src/views/admin/codexFingerprintSignals.ts new file mode 100644 index 0000000000..4edbc51cb7 --- /dev/null +++ b/frontend/src/views/admin/codexFingerprintSignals.ts @@ -0,0 +1,58 @@ +export type FingerprintSignalType = "header_exact" | "header_prefix" | "body_path"; + +export interface FingerprintSignalRow { + type: FingerprintSignalType; + match: string; // 变体用 " / " 展示与录入 + required: boolean; +} + +const VALID_TYPES: FingerprintSignalType[] = [ + "header_exact", + "header_prefix", + "body_path", +]; + +export function parseFingerprintSignalsToRows(raw: string): FingerprintSignalRow[] { + if (!raw || !raw.trim()) return []; + try { + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return []; + return arr.map((e) => ({ + type: VALID_TYPES.includes(e?.type) ? e.type : "header_exact", + match: Array.isArray(e?.match) + ? e.match.filter((x: unknown) => typeof x === "string").join(" / ") + : "", + required: e?.required === true, + })); + } catch { + return []; + } +} + +export function serializeFingerprintRowsToJSON(rows: FingerprintSignalRow[]): string { + const entries = rows + .map((r) => ({ + type: r.type, + match: r.match + .split("/") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + required: r.required === true, + })) + .filter((e) => e.match.length > 0); + return JSON.stringify(entries); +} + +export function defaultFingerprintSignalRows(): FingerprintSignalRow[] { + return [ + { type: "header_prefix", match: "x-codex-", required: true }, + { type: "header_exact", match: "session-id / session_id", required: false }, + { type: "header_exact", match: "thread-id / thread_id", required: false }, + { + type: "body_path", + match: + "client_metadata.x-codex-window-id / client_metadata.x-codex-installation-id", + required: false, + }, + ]; +}