sub2api/backend/internal/pkg/openai/allowed_client_test.go
DaydreamCoding 56908d3c4c feat(openai): codex_cli_only 新增放行 Claude Code Codex 插件的机制
适用场景:在 Claude Code 中使用 https://github.com/openai/codex-plugin-cc
插件时,插件经官方 codex app-server 以 clientInfo.name="Claude Code" 完成
initialize 握手,请求头被设为 originator=Claude Code、User-Agent 含
"Claude Code/",不在官方客户端白名单内,原本会被 codex_cli_only 拦截 403。

在官方客户端白名单未命中时评估两层独立放行(OR 语义):

- 按账号:account.Extra.codex_cli_only_allowed_clients 引用命名预设
  (目前仅 claude_code),detector reason=allowed_client_matched
- 全局开关:/admin/settings 网关服务 OpenAI 区块新增
  openai_allow_claude_code_codex_plugin(默认 false),开启后对所有
  codex_cli_only 账号统一放行,detector reason=global_allowed_client_matched

签名仍要求 originator=Claude Code 精确等值 + UA 含 "Claude Code/"。
上游转发保持透传不变。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:55:34 +08:00

96 lines
4.8 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"
// 真实的 Claude Code codex 插件请求头originator 与 UA 前缀同源于 clientInfo.name="Claude Code"。
const (
testClaudeCodeOriginator = "Claude Code"
testClaudeCodeUserAgent = "Claude Code/0.5.0 (Macos 15.5; arm64) iTerm2.app (Claude Code; 1.0.4)"
)
func TestIsAllowedClientMatch(t *testing.T) {
entry := AllowedClientEntry{Originator: "Claude Code", UAContains: []string{"Claude Code/"}}
tests := []struct {
name string
ua string
originator string
want bool
}{
{name: "真实签名命中", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, want: true},
{name: "大小写不敏感", ua: "claude code/0.5.0 (macos)", originator: "claude code", want: true},
{name: "originator 两侧空白被裁剪", ua: testClaudeCodeUserAgent, originator: " Claude Code ", want: true},
{name: "originator 非精确(带后缀)不命中", ua: testClaudeCodeUserAgent, originator: "Claude Code Extra", want: false},
{name: "originator 为空不命中", ua: testClaudeCodeUserAgent, originator: "", want: false},
{name: "originator 是官方 codex 不命中", ua: testClaudeCodeUserAgent, originator: "codex_cli_rs", want: false},
{name: "UA 缺少 Claude Code/ 标记不命中", ua: "curl/8.0", originator: testClaudeCodeOriginator, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsAllowedClientMatch(tt.ua, tt.originator, entry); got != tt.want {
t.Fatalf("IsAllowedClientMatch(%q, %q) = %v, want %v", tt.ua, tt.originator, got, tt.want)
}
})
}
}
func TestIsAllowedClientMatch_EmptyOriginatorEntryNeverMatches(t *testing.T) {
// registry 条目若没有配置 Originator绝不放行避免成为宽松后门。
entry := AllowedClientEntry{Originator: "", UAContains: []string{"Claude Code/"}}
if IsAllowedClientMatch(testClaudeCodeUserAgent, "", entry) {
t.Fatal("空 Originator 的条目不应匹配任何请求")
}
}
func TestIsAllowedClientMatch_EmptyUAContainsNeverMatches(t *testing.T) {
// 预设必须声明 UA 特征,否则退化为仅凭可伪造的 originator 单因子匹配,绝不放行。
entry := AllowedClientEntry{Originator: "Claude Code", UAContains: nil}
if IsAllowedClientMatch(testClaudeCodeUserAgent, testClaudeCodeOriginator, entry) {
t.Fatal("未声明 UA 特征的预设不应匹配,避免退化为单因子 originator 匹配")
}
}
func TestIsAllowedClientMatch_WhitespaceUAMarkerNeverMatches(t *testing.T) {
// 全空白 marker 归一化后为空,若被跳过则退化为仅 originator 单因子;
// 任何空白 marker 视为非法预设配置,必须安全失败。
entry := AllowedClientEntry{Originator: "Claude Code", UAContains: []string{" "}}
if IsAllowedClientMatch(testClaudeCodeUserAgent, testClaudeCodeOriginator, entry) {
t.Fatal("UAContains 含全空白 marker 不应匹配,避免退化为单因子 originator 匹配")
}
}
func TestIsAllowedClientMatch_MixedEmptyUAMarkerNeverMatches(t *testing.T) {
// 即便 UAContains 含一个真实 marker只要其中混入任何空白 marker 也视为非法配置;
// 防止维护者只为对齐凑数而插入空字符串。
entry := AllowedClientEntry{Originator: "Claude Code", UAContains: []string{"", "Claude Code/"}}
if IsAllowedClientMatch(testClaudeCodeUserAgent, testClaudeCodeOriginator, entry) {
t.Fatal("UAContains 混入空白 marker 不应匹配")
}
}
func TestMatchAllowedClients(t *testing.T) {
tests := []struct {
name string
ua string
originator string
clientIDs []string
want bool
}{
{name: "claude_code 预设命中真实签名", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{AllowedClientClaudeCode}, want: true},
{name: "claude_code 预设 + 伪造 originator 不命中", ua: testClaudeCodeUserAgent, originator: "my_client", clientIDs: []string{AllowedClientClaudeCode}, want: false},
{name: "空列表不放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: nil, want: false},
{name: "未知预设 ID 不放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{"unknown_client"}, want: false},
{name: "ID 大小写/空白容错", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{" Claude_Code "}, want: true},
{name: "多预设任一命中即放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{"unknown_client", AllowedClientClaudeCode}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := MatchAllowedClients(tt.ua, tt.originator, tt.clientIDs); got != tt.want {
t.Fatalf("MatchAllowedClients(%q, %q, %v) = %v, want %v", tt.ua, tt.originator, tt.clientIDs, got, tt.want)
}
})
}
}