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:
DaydreamCoding
2026-06-26 16:19:41 +08:00
co-authored by Claude Opus 4.8
parent f93a6c50ce
commit 819fda34d9
41 changed files with 2405 additions and 490 deletions
+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,
},
];
}