openai_codex_identity_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package service
  2. import (
  3. "net/http"
  4. "testing"
  5. "github.com/Wei-Shaw/sub2api/internal/config"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) {
  10. t.Helper()
  11. require.Equal(t, codexCLIUserAgent, h.Get("User-Agent"))
  12. require.Equal(t, openai.CodexDefaultOriginator, h.Get("Originator"))
  13. require.Equal(t, codexCLIVersion, h.Get("Version"))
  14. require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
  15. require.NotEmpty(t, h.Get("X-Codex-Window-ID"))
  16. }
  17. // 强制统一出口:无论客户端自报什么身份,OAuth 出站的 User-Agent / originator / version
  18. // 一律是网关规范身份。上游在容量紧张时按客户端身份分优先级降载,统一出口确保没有请求
  19. // 带着第三方或陈旧身份出站。
  20. func TestEnsureCodexIdentityHeaders(t *testing.T) {
  21. t.Run("补齐缺失身份头", func(t *testing.T) {
  22. h := make(http.Header)
  23. ensureCodexIdentityHeaders(h)
  24. enforceCodexIdentityHeaders(h)
  25. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  26. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  27. require.Equal(t, codexCLIVersion, h.Get("version"))
  28. require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
  29. })
  30. t.Run("官方非 CLI 客户端身份同样被统一", func(t *testing.T) {
  31. h := make(http.Header)
  32. h.Set("user-agent", "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)")
  33. h.Set("version", "9.9.9")
  34. h.Set("OpenAI-Beta", "assistants=v2")
  35. ensureCodexIdentityHeaders(h)
  36. enforceCodexIdentityHeaders(h)
  37. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  38. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  39. require.Equal(t, codexCLIVersion, h.Get("version"))
  40. require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
  41. })
  42. }
  43. func TestEnforceCodexIdentityHeaders(t *testing.T) {
  44. tests := []struct {
  45. name string
  46. originator string
  47. userAgent string
  48. version string
  49. }{
  50. {
  51. name: "TUI 身份",
  52. originator: "codex-tui",
  53. userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
  54. },
  55. {
  56. name: "错配 originator",
  57. originator: "codex_cli_rs",
  58. userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
  59. },
  60. {
  61. name: "官方 vscode 身份",
  62. originator: "codex_vscode",
  63. userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)",
  64. },
  65. {
  66. name: "第三方客户端身份",
  67. originator: "opencode",
  68. userAgent: "luna/1.0.0",
  69. version: "2.1.0",
  70. },
  71. {
  72. name: "浏览器型 UA(原浏览器兜底已被统一出口吸收)",
  73. originator: "codex_cli_rs",
  74. userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
  75. },
  76. {
  77. name: "UA 缺失",
  78. originator: "codex_vscode",
  79. },
  80. {
  81. name: "originator override 的真实 TUI 客户端",
  82. originator: "cccc",
  83. userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)",
  84. },
  85. {
  86. name: "陈旧客户端版本",
  87. originator: "codex_cli_rs",
  88. userAgent: "codex_cli_rs/0.125.0",
  89. version: "0.125.0",
  90. },
  91. }
  92. for _, tt := range tests {
  93. t.Run(tt.name, func(t *testing.T) {
  94. h := make(http.Header)
  95. h.Set("originator", tt.originator)
  96. if tt.userAgent != "" {
  97. h.Set("user-agent", tt.userAgent)
  98. }
  99. if tt.version != "" {
  100. h.Set("version", tt.version)
  101. }
  102. enforceCodexIdentityHeaders(h)
  103. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  104. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  105. require.Equal(t, codexCLIVersion, h.Get("version"))
  106. })
  107. }
  108. }
  109. // 账号级自定义 UA 是管理员的显式配置,仍然生效;但它只贡献客户端名与 OS / 架构 / 终端指纹,
  110. // originator 与版本段一律由规范身份重建,不允许出现自相矛盾或陈旧的身份。
  111. func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) {
  112. t.Run("官方形态覆写 UA 保留指纹但重建版本段", func(t *testing.T) {
  113. h := make(http.Header)
  114. h.Set("originator", "codex-tui")
  115. h.Set("user-agent", "luna/1.0.0")
  116. h.Set("version", "2.1.0")
  117. enforceCodexIdentityHeadersWithUA(h, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode")
  118. require.Equal(t, "codex_vscode", h.Get("originator"))
  119. require.Equal(t, "codex_vscode/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent"))
  120. require.Equal(t, codexCLIVersion, h.Get("version"))
  121. })
  122. t.Run("非官方形态覆写 UA 回退规范身份", func(t *testing.T) {
  123. h := make(http.Header)
  124. h.Set("originator", "codex_cli_rs")
  125. enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0")
  126. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  127. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  128. require.Equal(t, codexCLIVersion, h.Get("version"))
  129. })
  130. // 回归:覆写 UA 填写于某个历史版本时,其版本段必须被重建而不是逐字沿用——
  131. // 否则这条配置会绕过版本自动同步,把出站身份永久钉死在陈旧版本上,
  132. // 稳定落在上游优先降载的那一侧(UA 与 version 头也不再同源)。
  133. t.Run("陈旧覆写 UA 的版本段被重建", func(t *testing.T) {
  134. h := make(http.Header)
  135. h.Set("originator", "codex_cli_rs")
  136. enforceCodexIdentityHeadersWithUA(h, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color")
  137. require.Equal(t, "codex_cli_rs", h.Get("originator"))
  138. require.Equal(t, "codex_cli_rs/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent"))
  139. require.Equal(t, codexCLIVersion, h.Get("version"))
  140. require.NotContains(t, h.Get("user-agent"), "0.125.0")
  141. })
  142. // 陈旧覆写 UA 同样跟随自动同步到的新版本,无需管理员重新编辑那条 UA。
  143. t.Run("陈旧覆写 UA 跟随同步版本", func(t *testing.T) {
  144. SetCodexCanonicalUserAgentResolver(func() string {
  145. return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix
  146. })
  147. t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
  148. h := make(http.Header)
  149. h.Set("originator", "codex_cli_rs")
  150. enforceCodexIdentityHeadersWithUA(h, "codex-tui/0.125.0 (Mac OS X 14.0; arm64) iTerm")
  151. require.Equal(t, "codex-tui", h.Get("originator"))
  152. require.Equal(t, "codex-tui/0.200.1 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent"))
  153. require.Equal(t, "0.200.1", h.Get("version"))
  154. })
  155. }
  156. // 规范身份跟随注入的解析器(后台面板 UA / 自动同步版本号),无需重启或发版。
  157. //
  158. // 不得给本用例加 t.Parallel():它改写进程级解析器。
  159. func TestEnforceCodexIdentityHeadersFollowsCanonicalResolver(t *testing.T) {
  160. SetCodexCanonicalUserAgentResolver(func() string {
  161. return "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color"
  162. })
  163. t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
  164. h := make(http.Header)
  165. h.Set("originator", "codex-tui")
  166. h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
  167. enforceCodexIdentityHeaders(h)
  168. require.Equal(t, "codex_cli_rs", h.Get("originator"))
  169. require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent"))
  170. require.Equal(t, "0.200.1", h.Get("version"))
  171. }
  172. // 解析器返回非法值(配置被写坏、同步到异常内容)时必须回退到内置身份,
  173. // 绝不能把不可控内容拼进出站身份。
  174. //
  175. // 不得给本用例加 t.Parallel():它改写进程级解析器。
  176. func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) {
  177. SetCodexCanonicalUserAgentResolver(func() string { return "not-a-codex-client" })
  178. t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
  179. h := make(http.Header)
  180. h.Set("originator", "codex_cli_rs")
  181. enforceCodexIdentityHeaders(h)
  182. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  183. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  184. require.Equal(t, codexCLIVersion, h.Get("version"))
  185. }
  186. // 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「强制统一开启」
  187. // 一侧,否则任意一处零值构造都会静默关掉全局保护。
  188. //
  189. // 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。
  190. func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) {
  191. var cfg config.Config
  192. require.False(t, cfg.Gateway.DisableCodexIdentityEnforcement,
  193. "零值必须表示强制统一开启;若改为正向命名,零值会静默关闭保护")
  194. SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
  195. t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
  196. h := make(http.Header)
  197. h.Set("originator", "codex-tui")
  198. h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
  199. enforceCodexIdentityHeaders(h)
  200. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  201. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  202. }
  203. // 关闭强制统一后退回配对语义:客户端真实身份逐字保留,仅保证 originator 与 UA 首段配套
  204. // (issue #3901),供上游策略变动时回滚。
  205. func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) {
  206. const tuiUA = "codex-tui/0.145.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.2)"
  207. SetCodexIdentityEnforcementEnabled(false)
  208. t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
  209. h := make(http.Header)
  210. h.Set("originator", "codex-tui")
  211. h.Set("user-agent", tuiUA)
  212. h.Set("version", "0.145.2")
  213. enforceCodexIdentityHeaders(h)
  214. require.Equal(t, "codex-tui", h.Get("originator"))
  215. require.Equal(t, tuiUA, h.Get("user-agent"))
  216. require.Equal(t, "0.145.2", h.Get("version"))
  217. }
  218. // 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。
  219. func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) {
  220. SetCodexIdentityEnforcementEnabled(false)
  221. t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
  222. h := make(http.Header)
  223. h.Set("originator", "opencode")
  224. h.Set("user-agent", "luna/1.0.0")
  225. h.Set("version", "2.1.0")
  226. enforceCodexIdentityHeaders(h)
  227. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  228. require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
  229. require.Equal(t, codexCLIVersion, h.Get("version"))
  230. }
  231. // 收口必须幂等:透传等路径可能先后多次经过收口。
  232. func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) {
  233. h := make(http.Header)
  234. h.Set("originator", "codex-tui")
  235. h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
  236. enforceCodexIdentityHeaders(h)
  237. firstUA, firstVersion := h.Get("user-agent"), h.Get("version")
  238. enforceCodexIdentityHeaders(h)
  239. require.Equal(t, firstUA, h.Get("user-agent"))
  240. require.Equal(t, firstVersion, h.Get("version"))
  241. require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
  242. }
  243. // 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除
  244. // originator,不应被补回身份头。
  245. func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
  246. h := make(http.Header)
  247. h.Set("user-agent", "third-party-client/1.0.0")
  248. enforceCodexIdentityHeaders(h)
  249. require.Empty(t, h.Get("originator"))
  250. require.Empty(t, h.Get("version"))
  251. require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent"))
  252. }
  253. func TestNormalizeCodexClientVersion(t *testing.T) {
  254. require.Equal(t, "0.146.0", NormalizeCodexClientVersion(" 0.146.0 "))
  255. require.Equal(t, "0.147.0-alpha.4", NormalizeCodexClientVersion("0.147.0-alpha.4"))
  256. require.Equal(t, "1.2", NormalizeCodexClientVersion("1.2"))
  257. require.Empty(t, NormalizeCodexClientVersion(""))
  258. require.Empty(t, NormalizeCodexClientVersion("v0.146.0"))
  259. require.Empty(t, NormalizeCodexClientVersion("0.146.0 (Ubuntu)"))
  260. require.Empty(t, NormalizeCodexClientVersion("0.146.0\r\nX-Injected: 1"))
  261. require.Empty(t, NormalizeCodexClientVersion("latest"))
  262. }
  263. func TestBuildCodexCLIUserAgent(t *testing.T) {
  264. require.Equal(t, openai.CodexDefaultOriginator+"/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
  265. // 非法版本号必须回退到内置 UA,不能拼出畸形身份。
  266. require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version"))
  267. require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent(""))
  268. }
  269. func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) {
  270. SetCodexCanonicalUserAgentResolver(func() string {
  271. return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix
  272. })
  273. t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
  274. require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, CodexCanonicalUserAgent())
  275. require.Equal(t, "0.200.1", CodexCanonicalClientVersion())
  276. h := make(http.Header)
  277. ApplyCodexCanonicalAuthIdentity(h)
  278. require.Equal(t, "codex_cli_rs", h.Get("originator"))
  279. require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent"))
  280. // 凭据面不发 version 头(真实客户端在 auth.openai.com 只带 originator + UA)。
  281. require.Empty(t, h.Get("version"))
  282. }
  283. func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) {
  284. SetCodexCanonicalUserAgentResolver(nil)
  285. require.Equal(t, codexCLIUserAgent, CodexCanonicalUserAgent())
  286. require.Equal(t, codexCLIVersion, CodexCanonicalClientVersion())
  287. }