Files
sub2api/backend/internal/service/setting_service_codex_policy_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

205 lines
8.5 KiB
Go

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])
})
}