Files
sub2api/backend/internal/pkg/openai/request_test.go
T
DaydreamCodingandClaude Opus 4.8 819fda34d9 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>
2026-06-26 16:19:41 +08:00

186 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package openai
import "testing"
func TestIsCodexCLIRequest(t *testing.T) {
tests := []struct {
name string
ua string
want bool
}{
{name: "codex_cli_rs 前缀", ua: "codex_cli_rs/0.1.0", want: true},
{name: "codex_vscode 前缀", ua: "codex_vscode/1.2.3", want: true},
{name: "大小写混合", ua: "Codex_CLI_Rs/0.1.0", want: true},
{name: "复合 UA 包含 codex", ua: "Mozilla/5.0 codex_cli_rs/0.1.0", want: true},
{name: "空白包裹", ua: " codex_vscode/1.2.3 ", want: true},
{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 := IsCodexCLIRequest(tt.ua)
if got != tt.want {
t.Fatalf("IsCodexCLIRequest(%q) = %v, want %v", tt.ua, got, tt.want)
}
})
}
}
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
ua string
want bool
}{
{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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsCodexOfficialClientRequest(tt.ua)
if got != tt.want {
t.Fatalf("IsCodexOfficialClientRequest(%q) = %v, want %v", tt.ua, got, tt.want)
}
})
}
}
func TestIsCodexOfficialClientOriginator(t *testing.T) {
tests := []struct {
name string
originator string
want bool
}{
{name: "codex_cli_rs", originator: "codex_cli_rs", want: true},
{name: "codex_vscode", originator: "codex_vscode", want: true},
{name: "codex_app", originator: "codex_app", want: true},
{name: "codex_chatgpt_desktop", originator: "codex_chatgpt_desktop", want: true},
{name: "codex_atlas", originator: "codex_atlas", want: true},
{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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsCodexOfficialClientOriginator(tt.originator)
if got != tt.want {
t.Fatalf("IsCodexOfficialClientOriginator(%q) = %v, want %v", tt.originator, got, tt.want)
}
})
}
}
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
ua string
originator string
want bool
}{
{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},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsCodexOfficialClientByHeaders(tt.ua, tt.originator)
if got != tt.want {
t.Fatalf("IsCodexOfficialClientByHeaders(%q, %q) = %v, want %v", tt.ua, tt.originator, got, tt.want)
}
})
}
}