Merge pull request #3495 from DaydreamCoding/feat/codex-detect-cli-only-engine-fingerprint

feat(codex-detect): codex_cli_only 检测加固 + 引擎指纹统一信号列表 + 账号级 app-server
This commit is contained in:
Wesley Liddick
2026-06-26 16:41:23 +08:00
committed by GitHub
41 changed files with 2405 additions and 490 deletions
@@ -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")
}
@@ -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 应允许(非对称)")
}
+8 -1
View File
@@ -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"`
+61 -28
View File
@@ -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
}
}
@@ -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{{}}), "全空条目安全忽略")
}
@@ -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))
}
@@ -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)
}
})
}
}
@@ -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)
}
})
}
}
@@ -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)
}
@@ -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)
})
}
+143 -14
View File
@@ -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
}
@@ -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},
}
@@ -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)
})
}
}
+12 -2
View File
@@ -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": []
},
+8 -29
View File
@@ -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 窗口费用调度状态
@@ -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())
})
}
@@ -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())
})
}
+17 -2
View File
@@ -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"
// 余额不足提醒
@@ -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}
}
@@ -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)
})
}
@@ -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)
})
}
@@ -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)
@@ -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 {
@@ -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)
@@ -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"}]}`)
+283 -51
View File
@@ -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 != "" {
@@ -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])
})
}
@@ -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"]}]`))
}
@@ -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()))
})
}
+6 -1
View File
@@ -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 模拟
+6
View File
@@ -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
}
+14 -2
View File
@@ -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;
@@ -742,44 +742,44 @@
</div>
</div>
<!-- OpenAI OAuth: 额外放行 Claude Code 的 Codex 插件 -->
<!-- OpenAI OAuth: Codex app-server -->
<div v-if="allOpenAIOAuth" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label
id="bulk-edit-openai-codex-allow-claude-code-label"
id="bulk-edit-openai-codex-app-server-label"
class="input-label mb-0"
for="bulk-edit-openai-codex-allow-claude-code-enabled"
for="bulk-edit-openai-codex-app-server-enabled"
>
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}
{{ t('admin.accounts.openai.codexCLIOnlyAppServer') }}
</label>
<input
v-model="enableCodexCLIOnlyAllowClaudeCode"
id="bulk-edit-openai-codex-allow-claude-code-enabled"
v-model="enableCodexCLIOnlyAppServer"
id="bulk-edit-openai-codex-app-server-enabled"
type="checkbox"
aria-controls="bulk-edit-openai-codex-allow-claude-code"
aria-controls="bulk-edit-openai-codex-app-server"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-openai-codex-allow-claude-code"
:class="!enableCodexCLIOnlyAllowClaudeCode && 'pointer-events-none opacity-50'"
id="bulk-edit-openai-codex-app-server"
:class="!enableCodexCLIOnlyAppServer && 'pointer-events-none opacity-50'"
>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
{{ t('admin.accounts.openai.codexCLIOnlyAppServerDesc') }}
</p>
<button
id="bulk-edit-openai-codex-allow-claude-code-toggle"
id="bulk-edit-openai-codex-app-server-toggle"
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
codexCLIOnlyAppServerEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
@click="codexCLIOnlyAppServerEnabled = !codexCLIOnlyAppServerEnabled"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
codexCLIOnlyAppServerEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
@@ -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<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAllowClaudeCodeEnabled = ref(false)
const codexCLIOnlyAppServerEnabled = ref(false)
const openAICompactMode = ref<OpenAICompactMode>('auto')
const openAICompactModelMappings = ref<ModelMapping[]>([])
const rpmLimitEnabled = ref(false)
@@ -1542,9 +1542,15 @@ const buildUpdatePayload = (): Record<string, unknown> | 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
@@ -2700,23 +2700,23 @@
class="mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}</label>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAppServer') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
{{ t('admin.accounts.openai.codexCLIOnlyAppServerDesc') }}
</p>
</div>
<button
type="button"
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
@click="codexCLIOnlyAppServerEnabled = !codexCLIOnlyAppServerEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
codexCLIOnlyAppServerEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
codexCLIOnlyAppServerEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
@@ -3496,7 +3496,7 @@ const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_comple
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(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<string, unknown>): Record<string, unknow
} else {
delete extra.codex_cli_only
}
delete extra.codex_cli_only_allowed_clients
if (
accountCategory.value === 'oauth-based' &&
codexCLIOnlyEnabled.value &&
codexCLIOnlyAllowClaudeCodeEnabled.value
codexCLIOnlyAppServerEnabled.value
) {
extra.codex_cli_only_allowed_clients = ['claude_code']
extra.codex_cli_only_allow_app_server = true
} else {
delete extra.codex_cli_only_allowed_clients
delete extra.codex_cli_only_allow_app_server
}
if (openAICompactMode.value !== 'auto') {
extra.openai_compact_mode = openAICompactMode.value
@@ -1693,23 +1693,23 @@
class="mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-4 dark:border-dark-600"
>
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCode') }}</label>
<label class="input-label mb-0">{{ t('admin.accounts.openai.codexCLIOnlyAppServer') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.codexCLIOnlyAllowClaudeCodeDesc') }}
{{ t('admin.accounts.openai.codexCLIOnlyAppServerDesc') }}
</p>
</div>
<button
type="button"
@click="codexCLIOnlyAllowClaudeCodeEnabled = !codexCLIOnlyAllowClaudeCodeEnabled"
@click="codexCLIOnlyAppServerEnabled = !codexCLIOnlyAppServerEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
codexCLIOnlyAllowClaudeCodeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
codexCLIOnlyAppServerEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
codexCLIOnlyAllowClaudeCodeEnabled ? 'translate-x-5' : 'translate-x-0'
codexCLIOnlyAppServerEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
@@ -2603,7 +2603,7 @@ const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_comple
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const codexCLIOnlyAllowClaudeCodeEnabled = ref(false)
const codexCLIOnlyAppServerEnabled = ref(false)
type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled'
const codexImageGenerationBridgeMode = ref<CodexImageGenerationBridgeMode>('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<string, unknown> | undefined
const compactMappings = credentials?.compact_model_mapping as Record<string, string> | 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
}
}
@@ -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'],
+38 -6
View File
@@ -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',
+37 -5
View File
@@ -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 模拟',
+360 -14
View File
@@ -3724,6 +3724,254 @@
</div>
</div>
<!-- Codex Settings -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.gatewayForwarding.codexHardeningTitle") }}
</h2>
</div>
<div class="p-6 space-y-4">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.gatewayForwarding.codexClientRestrictionTitle") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.codexHardeningDesc") }}
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.minCodexVersion") }}
</label>
<input
v-model="form.min_codex_version"
type="text"
class="input w-full font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.minCodexVersionPlaceholder',
)
"
/>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.maxCodexVersion") }}
</label>
<input
v-model="form.max_codex_version"
type="text"
class="input w-full font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.maxCodexVersionPlaceholder',
)
"
/>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.codexVersionHint") }}
</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.gatewayForwarding.codexFingerprintSignals") }}
</label>
<p class="mb-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.codexFingerprintSignalsDesc") }}
</p>
<div
v-for="(row, i) in codexFingerprintRows"
:key="`codex-fp-${i}`"
class="mb-2 flex items-center gap-2"
>
<select v-model="row.type" class="input w-32 text-sm">
<option value="header_exact">{{ t("admin.settings.gatewayForwarding.codexFpTypeHeaderExact") }}</option>
<option value="header_prefix">{{ t("admin.settings.gatewayForwarding.codexFpTypeHeaderPrefix") }}</option>
<option value="body_path">{{ t("admin.settings.gatewayForwarding.codexFpTypeBodyPath") }}</option>
</select>
<input
v-model="row.match"
type="text"
class="input flex-1 font-mono text-sm"
:placeholder="t('admin.settings.gatewayForwarding.codexFpMatchPlaceholder')"
/>
<label class="flex shrink-0 items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<input v-model="row.required" type="checkbox" />
{{ t("admin.settings.gatewayForwarding.codexFpRequired") }}
</label>
<button
type="button"
class="btn btn-secondary btn-sm shrink-0 text-red-600 hover:text-red-700 dark:text-red-400"
@click="removeCodexFingerprintRow(i)"
>
{{ t("admin.settings.gatewayForwarding.codexRemoveRow") }}
</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addCodexFingerprintRow">
{{ t("admin.settings.gatewayForwarding.codexAddRow") }}
</button>
<p
v-if="codexFingerprintNoRequired"
class="mt-2 text-xs text-amber-600 dark:text-amber-500"
>
{{ t("admin.settings.gatewayForwarding.codexFingerprintNoRequiredWarn") }}
</p>
</div>
<div class="flex items-center justify-between">
<div class="pr-4">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t("admin.settings.gatewayForwarding.codexAllowAppServer")
}}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.codexAllowAppServerDesc",
)
}}
</p>
</div>
<Toggle
v-model="form.codex_cli_only_allow_app_server_clients"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.codexBlacklist") }}
</label>
<p class="mb-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.codexBlacklistDesc") }}
</p>
<div
v-for="(row, i) in codexBlacklistRows"
:key="`codex-bl-${i}`"
class="mb-2 flex gap-2"
>
<input
v-model="row.originator"
type="text"
class="input w-1/3 font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.codexOriginatorPlaceholder',
)
"
/>
<input
v-model="row.uaContains"
type="text"
class="input flex-1 font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.codexUaContainsPlaceholder',
)
"
/>
<button
type="button"
class="btn btn-secondary btn-sm shrink-0 text-red-600 hover:text-red-700 dark:text-red-400"
@click="removeCodexBlacklistRow(i)"
>
{{ t("admin.settings.gatewayForwarding.codexRemoveRow") }}
</button>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="addCodexBlacklistRow"
>
{{ t("admin.settings.gatewayForwarding.codexAddRow") }}
</button>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.gatewayForwarding.codexWhitelist") }}
</label>
<p class="mb-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.codexWhitelistDesc") }}
</p>
<div
v-for="(row, i) in codexWhitelistRows"
:key="`codex-wl-${i}`"
class="mb-2 flex gap-2"
>
<input
v-model="row.originator"
type="text"
class="input w-1/3 font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.codexOriginatorPlaceholder',
)
"
/>
<input
v-model="row.uaContains"
type="text"
class="input flex-1 font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.codexUaContainsPlaceholder',
)
"
/>
<label
class="flex shrink-0 items-center gap-1 text-xs text-gray-600 dark:text-gray-400"
:title="
t(
'admin.settings.gatewayForwarding.codexWhitelistSkipFingerprintTooltip',
)
"
>
<input
v-model="row.skipEngineFingerprint"
type="checkbox"
/>
{{
t(
'admin.settings.gatewayForwarding.codexWhitelistSkipFingerprint',
)
}}
</label>
<button
type="button"
class="btn btn-secondary btn-sm shrink-0 text-red-600 hover:text-red-700 dark:text-red-400"
@click="removeCodexWhitelistRow(i)"
>
{{ t("admin.settings.gatewayForwarding.codexRemoveRow") }}
</button>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="addCodexWhitelistRow"
>
{{ t("admin.settings.gatewayForwarding.codexAddRow") }}
</button>
</div>
</div>
</div>
<!-- Gateway Scheduling Settings -->
<div class="card">
<div
@@ -4180,20 +4428,9 @@
</p>
</div>
<!-- 是否允许在 Claude Code 中使用 Codex 插件(全局开关) -->
<div class="flex items-center justify-between">
<div class="pr-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.gatewayForwarding.openaiAllowClaudeCodeCodexPlugin") }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.gatewayForwarding.openaiAllowClaudeCodeCodexPluginDesc") }}
</p>
</div>
<Toggle v-model="form.openai_allow_claude_code_codex_plugin" />
</div>
</div>
</div>
<!-- Web Search Emulation -->
<div class="card">
<div
@@ -7007,6 +7244,12 @@ import {
normalizeRegistrationEmailSuffixDomains,
parseRegistrationEmailSuffixWhitelistInput,
} from "@/utils/registrationEmailPolicy";
import {
parseFingerprintSignalsToRows,
serializeFingerprintRowsToJSON,
defaultFingerprintSignalRows,
type FingerprintSignalRow,
} from "./codexFingerprintSignals";
const { t, locale } = useI18n();
const appStore = useAppStore();
@@ -7837,7 +8080,13 @@ const form = reactive<SettingsForm>({
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<CodexClientRow[]>([]);
const codexWhitelistRows = ref<CodexClientRow[]>([]);
const codexFingerprintRows = ref<FingerprintSignalRow[]>([]);
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,
@@ -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("[]");
});
});
@@ -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,
},
];
}