mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(codex-detect): codex_cli_only 检测加固 + 引擎指纹统一信号列表 + 账号级 app-server
将 codex_cli_only 客户端识别从「单一 strict 开关 + 固定 OR 头集合」重构为 可逐项管理的引擎指纹信号列表,加固整条判定链,并补齐账号级 app-server 控制、 对齐前端设置文案。 判定链(每步可短路): - 账号未开 codex_cli_only → 不限制;gateway.force_codex_cli → 旁路放行 - 全局黑名单命中(OR 宽 deny)→ 立即拒 - 身份候选:官方 UA(strict,仅前缀)/ 官方 originator(OR)/ 全局白名单(双因子 AND) / 全局 app-server 开关 OR 账号 app-server 开关;均不命中 → 拒 - 版本门(仅官方候选):UA 须可解析引擎版本,再校验 [min,max] 区间 - 引擎指纹 AND 硬门:按信号列表逐条勾选 AND、每条行内变体 OR;无 Required 信号 → 放行 引擎指纹信号列表(唯一真源) - 新增 openai.EngineFingerprintSignal 类型 + EvaluateEngineFingerprint 求值器 (勾选 AND / 行内变体 OR / 无勾选 → 放行) - CodexRestrictionPolicy 增 EngineFingerprintSignals;信号列表单一决定是否启用指纹门, 不再保留独立「要求引擎指纹」总开关(与「信号全不选」语义重复) - 新设置键 codex_cli_only_engine_fingerprint_signals(默认只勾 x-codex- 前缀); 旧 body 指纹开关幂等迁移并入信号列表;wire 接线 - 黑/白名单自由条目、命名预设、版本区间 全局设置管线 - gateway 缺 settingService(仅测试/误配可达)时指纹门回退默认种子信号、失败关闭, 不再因零值 policy(nil 信号)失败开放 账号级 Codex app-server(替换已失效的 ClaudeCode 放行机制) - account.IsCodexCLIOnlyAppServerAllowed() 读 extra.codex_cli_only_allow_app_server, 仅在 codex_cli_only 开启时生效;候选身份门「全局 OR 账号」,与旧系统双层控制对齐 - 移除已无入口的 claude_code 预设机制(allowedClientRegistry / MatchAllowedClients / 账号 GetCodexCLIOnlyAllowedClients / reason);白名单 AllowedClientEntry / IsAllowedClientMatch 保留 门加固(反伪 + 写入校验) - 官方 UA 访问门改 strict:IsCodexOfficialClientRequestStrict 仅前缀匹配,收窄「浏览器前缀 + 中段 codex token」伪造面(strict 仍保留 Codex 家族前缀与 UA 尾部兜底,故对「任意前缀 + 官方尾部 (name;ver)」仍放行——与 UA 可伪造、真正反伪靠引擎指纹门的设计一致) - 官方客户端识别扩展:新增 codex-tui/、codex_vscode_copilot/ 前缀 + UA 尾部 (name;ver) 兜底 (恢复 CODEX_INTERNAL_ORIGINATOR_OVERRIDE 的真实 client,如 cccc→codex-tui),originator 改 精确集。该识别经 IsCodexOfficialClientByHeaders 被 passthrough 复用,故透传的官方判定一并 修正(codex-tui 等不再被误改写 UA)——非「行为不变」,属有意修正 - 白名单写入校验 ValidateCodexWhitelistEntriesJSON + AllowedClientEntry.IsWhitelistable: 双因子 AND 条目须可命中(非空 originator + 非空 ua_contains),拒绝写入会静默失效的死规则; 黑名单(OR 宽 deny,允许 originator-only)不受约束 管理端 / 前端 - handler / DTO / settings_view / 契约测试;gateway 接入判定链 - 信号列表编辑器(替换 body 开关)、api 类型、SettingsView;无勾选给常驻警告 - Create/Edit/Bulk 三弹窗「Codex Only」下新增 app-server 开关(OR 合并全局) - 文案:UA/Originator → User-Agent/Originator;黑/白名单重命名为 User-Agent/Originator 黑/白名单; 「允许 App Server 第三方客户端」→「Codex app-server」+ 简介示例;i18n zh/en 同步 - 移除死代码 HasCodex*Fingerprint helper 测试:引擎指纹求值器 / 账号 app-server(OR 语义)/ detector(含 N1 strict、失败关闭)/ 白名单写入校验 / BulkEdit spec 等;后端 build + service/openai/admin 单测全绿,前端 vue-tsc + vitest 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f93a6c50ce
commit
819fda34d9
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user