Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/internal/codexruntime/profile/identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
extensionv1 "github.com/Wei-Shaw/sub2api/internal/nativeapi"
)

func TestIdentityPolicyPreservesDeterminismAndProtocolPairing(t *testing.T) {
func TestIdentityProfilePreservesDeterminismAndProtocolPairing(t *testing.T) {
first := deriveCodexClientIdentity("1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55")
if first != deriveCodexClientIdentity("1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55") || !first.valid() {
t.Fatal("stable profile changed")
Expand Down
22 changes: 11 additions & 11 deletions backend/internal/service/account_test_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -963,17 +963,17 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
req.Host = "chatgpt.com"
req.Header.Set("accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
canonical, identityErr := resolveCodexOutboundIdentityForAccountContext(req.Context(), credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
canonical, identityErr := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
if identityErr != nil {
return s.sendErrorAndEnd(c, "Codex identity policy unavailable: "+identityErr.Error())
return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+identityErr.Error())
}
req.Header.Set("Originator", canonical.originator)
req.Header.Set("User-Agent", canonical.userAgent)
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 同样作为管理员
// 显式配置传入,否则测试用的身份与该账号真实出站的身份不是同一个。
if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil {
return s.sendErrorAndEnd(c, "Codex identity policy unavailable: "+err.Error())
if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil {
return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+err.Error())
}
}

Expand All @@ -995,7 +995,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
} else if req, err = prepareOpenAICodexWireRequest(req, account); err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
base := resolveCodexIdentitySnapshotContext(ctx, account, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
base := resolveCodexIdentitySnapshot(account, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
snapshot := base.withWire(transport, req.Header)
s.sendEvent(c, TestEvent{Type: "status", Text: snapshot.summary(), Data: snapshot})
s.sendEvent(c, TestEvent{Type: "status", Text: "Codex wire headers", Data: accountTestWireHeaders(req.Header)})
Expand Down Expand Up @@ -2325,8 +2325,8 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
if isOAuth {
// 与真实转发一致:使用该账号的 Codex TUI 身份,账号级自定义 UA 经 ForceCodexCLI
// 策略过滤后作为管理员显式配置传入(同普通 OAuth 连接测试)。
if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil {
return s.sendErrorAndEnd(c, "Codex identity policy unavailable: "+err.Error())
if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil {
return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+err.Error())
}
}
probeSessionID := compactProbeSessionID(account.ID)
Expand Down Expand Up @@ -3021,17 +3021,17 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
canonical, identityErr := resolveCodexOutboundIdentityForAccountContext(req.Context(), credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
canonical, identityErr := resolveCodexOutboundIdentityForAccount(credentialAccount, codexAccountIdentityOverrideUA(credentialAccount))
if identityErr != nil {
return s.sendErrorAndEnd(c, "Codex identity policy unavailable: "+identityErr.Error())
return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+identityErr.Error())
}
req.Header.Set("originator", canonical.originator)
req.Header.Set("User-Agent", canonical.userAgent)
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
// 与真实转发一致(同普通 OAuth 连接测试):使用该账号的 Codex TUI 身份,账号级自定义 UA
// 经 ForceCodexCLI 策略过滤后作为管理员显式配置传入。
if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil {
return s.sendErrorAndEnd(c, "Codex identity policy unavailable: "+err.Error())
if err := enforceCodexIdentityHeadersForAccount(req.Header, credentialAccount, codexAccountIdentityOverrideUA(credentialAccount)); err != nil {
return s.sendErrorAndEnd(c, "Codex client identity is invalid: "+err.Error())
}

proxyURL := ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,9 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactProbeIdentityMatc
require.NotContains(t, upstream.lastReq.Header.Get("session-id"), "probe_compact",
"探测标识不得是可被上游一眼识别的字面量")
// 出站 UA = 该账号(种子派生)的 Codex TUI 身份,而不是全局规范身份。
require.Equal(t, resolveCodexOutboundIdentityForAccount(&account, "").userAgent, upstream.lastReq.Header.Get("User-Agent"))
identity, err := resolveCodexOutboundIdentityForAccount(&account, "")
require.NoError(t, err)
require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent"))
<-updateCalls
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes
require.NotNil(t, upstream.lastReq)
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
// Force 优先于自定义 UA;无种子账号使用规范身份。压缩后仍是 image_generation 工具调用。
require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, upstream.lastReq.Header.Get("User-Agent"))
identity, err := resolveCodexOutboundIdentityForAccount(account, "")
require.NoError(t, err)
require.Equal(t, identity.userAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex-tui", upstream.lastReq.Header.Get("originator"))
require.Equal(t, "zstd", upstream.lastReq.Header.Get("Content-Encoding"))
require.Equal(t, "image_generation", gjson.GetBytes(zstdDecodeForTest(t, upstream.lastBody), "tools.0.type").String())
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/account_usage_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。
// 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义);
// 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。
if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, account, codexAccountIdentityOverrideUA(account)); err != nil {
if err := enforceCodexIdentityHeadersForAccount(req.Header, account, codexAccountIdentityOverrideUA(account)); err != nil {
return nil, err
}
setOpenAIChatGPTAccountHeaders(req.Header, account)
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/codex_outbound_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ func (s *OpenAIGatewayService) finalizeCodexOutboundHeaders(ctx context.Context,
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil {
return err
}
return enforceCodexIdentityHeadersForAccountContext(ctx, headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account))
return enforceCodexIdentityHeadersForAccount(headers, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account))
}
4 changes: 2 additions & 2 deletions backend/internal/service/openai_alpha_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
fillCodexSessionIdentityHeaders(req.Header, generateSessionUUID(sessionID))
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID)
if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil {
if err := enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil {
return nil, err
}
account.ApplyHeaderOverrides(req.Header)
Expand Down Expand Up @@ -465,7 +465,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
req.Header.Set("User-Agent", canonical.userAgent)
}
applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
if err := enforceCodexIdentityHeadersForAccountContext(req.Context(), req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil {
if err := enforceCodexIdentityHeadersForAccount(req.Header, codexAccountIdentitySource(c, account), s.codexIdentityOverrideUA(account)); err != nil {
return nil, err
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t
"token", true, "client-session", true,
)
require.NoError(t, err)
require.Equal(t, resolveCodexOutboundIdentityForAccount(parent, "").userAgent, req.Header.Get("user-agent"), "影子账号使用凭据账号的身份")
identity, err := resolveCodexOutboundIdentityForAccount(parent, "")
require.NoError(t, err)
require.Equal(t, identity.userAgent, req.Header.Get("user-agent"), "影子账号使用凭据账号的身份")
require.Empty(t, req.Header.Get("session_id"))
require.Equal(t, "client-session", req.Header.Get("session-id"), "缺失连字符会话头时以最终 prompt_cache_key 补齐")

Expand Down
25 changes: 15 additions & 10 deletions backend/internal/service/openai_codex_client_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func TestRefreshAccountTokenUsesAccountCodexIdentity(t *testing.T) {
info, err := svc.RefreshAccountToken(context.Background(), account)
require.NoError(t, err)
require.Equal(t, "with-identity", info.AccessToken)
expected := resolveCodexOutboundIdentityForAccount(account, "")
expected, err := resolveCodexOutboundIdentityForAccount(account, "")
require.NoError(t, err)
require.Equal(t, expected.userAgent, stub.userAgent)
require.Equal(t, "codex-tui", stub.originator)
}
Expand All @@ -62,17 +63,19 @@ func TestCodexIdentitySnapshotIsSecretFreeAndConsistent(t *testing.T) {
Credentials: map[string]any{"chatgpt_account_id": "acct-9"},
Extra: map[string]any{codexFingerprintSeedExtraKey: seed}}

snapshot := resolveCodexIdentitySnapshotContext(context.Background(), account, account, "")
snapshot := resolveCodexIdentitySnapshot(account, account, "")
require.Equal(t, "account", snapshot.IdentitySource)
require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, snapshot.UserAgent)
identity, err := resolveCodexOutboundIdentityForAccount(account, "")
require.NoError(t, err)
require.Equal(t, identity.userAgent, snapshot.UserAgent)
raw, err := json.Marshal(snapshot)
require.NoError(t, err)
require.NotContains(t, string(raw), seed)

t.Cleanup(func() { SetCodexForceCLIEnabled(false) })
account.Credentials["user_agent"] = "codex_cli_rs/0.150.0 (Windows 10.0.19045; x86_64) unknown"
SetCodexForceCLIEnabled(true)
forced := resolveCodexIdentitySnapshotContext(context.Background(), account, account, codexAccountIdentityOverrideUA(account))
forced := resolveCodexIdentitySnapshot(account, account, codexAccountIdentityOverrideUA(account))
require.Equal(t, "account", forced.IdentitySource)
require.Equal(t, deriveCodexClientIdentity(seed).UserAgent(forced.Version), forced.UserAgent,
"ForceCodexCLI drops the custom UA while retaining the account's derived TUI profile")
Expand All @@ -83,15 +86,15 @@ func TestCodexIdentitySnapshotReportsTheSelectedFingerprintSource(t *testing.T)
t.Cleanup(func() { SetCodexForceCLIEnabled(false) })
account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}}
snapshot := resolveCodexIdentitySnapshotContext(context.Background(), account, account, "")
snapshot := resolveCodexIdentitySnapshot(account, account, "")
require.Equal(t, "account", snapshot.IdentitySource)
require.False(t, snapshot.UAOverridePresent)
snapshot = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "codex_cli_rs/0.144.0 (Windows 10.0.19045; x86_64) unknown")
snapshot = resolveCodexIdentitySnapshot(account, account, "codex_cli_rs/0.144.0 (Windows 10.0.19045; x86_64) unknown")
require.Equal(t, "override_ua", snapshot.IdentitySource)
require.True(t, snapshot.UAOverridePresent)
// An override that does not pair as a Codex identity leaves the canonical
// identity in place; the flag is then the only sign that one is configured.
snapshot = resolveCodexIdentitySnapshotContext(context.Background(), account, account, "explicit UA override")
snapshot = resolveCodexIdentitySnapshot(account, account, "explicit UA override")
require.Equal(t, "canonical", snapshot.IdentitySource)
require.True(t, snapshot.UAOverridePresent)
}
Expand All @@ -117,10 +120,10 @@ func TestCodexIdentityInvalidVersionCannotSendCanonicalFallback(t *testing.T) {
Extra: map[string]any{codexFingerprintSeedExtraKey: "1c0a3d9e-58b2-4f8c-a2d1-7f3b9e6c4a55"}}
require.Equal(t, "9.9.9_bad", resolveCodexOutboundIdentity("").version)

_, err := resolveCodexOutboundIdentityForAccountContext(context.Background(), account, "")
_, err := resolveCodexOutboundIdentityForAccount(account, "")
require.ErrorIs(t, err, codexprofile.ErrInvalidVersion)
headers := http.Header{"Originator": []string{"fixture-original"}}
err = enforceCodexIdentityHeadersForAccountContext(context.Background(), headers, account, "")
err = enforceCodexIdentityHeadersForAccount(headers, account, "")
require.ErrorIs(t, err, codexprofile.ErrInvalidVersion)
require.Equal(t, "fixture-original", headers.Get("Originator"))
require.Empty(t, headers.Get("User-Agent"))
Expand Down Expand Up @@ -243,7 +246,9 @@ func TestRefreshAccountTokenSharesForceCodexCLIOverridePolicy(t *testing.T) {
stub = &identityRefreshingOAuthClientStub{}
_, err = NewOpenAIOAuthService(nil, stub).RefreshAccountToken(context.Background(), account)
require.NoError(t, err)
require.Equal(t, resolveCodexOutboundIdentityForAccount(account, "").userAgent, stub.userAgent, "ForceCodexCLI 时与推理面一样忽略显式 UA")
identity, err := resolveCodexOutboundIdentityForAccount(account, "")
require.NoError(t, err)
require.Equal(t, identity.userAgent, stub.userAgent, "ForceCodexCLI 时与推理面一样忽略显式 UA")
}

// codexTestSeedForOS 返回一个派生出指定操作系统身份的合法种子(确定性搜索)。
Expand Down
20 changes: 5 additions & 15 deletions backend/internal/service/openai_codex_identity.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package service

import (
"context"
"net/http"
"regexp"
"strings"
Expand Down Expand Up @@ -275,15 +274,10 @@ func pairCodexIdentityHeaders(h http.Header) {
// 优先级:管理员显式配置的账号级 User-Agent(只贡献客户端名与 OS / 架构 / 终端指纹,
// 版本段仍由生效版本重建)> 账号持久化 / 种子派生的 Codex TUI 身份 > 全局规范身份。
// 版本号三处同源:UA 首段、UA 尾部括号组与 version 头都取当前生效的官方版本。
func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) codexOutboundIdentity {
identity, _ := resolveCodexOutboundIdentityForAccountContext(context.Background(), account, overrideUA)
return identity
}

// resolveCodexOutboundIdentityForAccountContext 与 resolveCodexOutboundIdentityForAccount 同语义,
// 并把唯一的失败原因交还调用方:生效版本号无法写进该账号身份的 User-Agent 时返回错误,
//
// 唯一的失败原因:生效版本号无法写进该账号身份的 User-Agent。此时返回错误,
// 调用方必须让本次发送失败,而不是退回全局规范身份出站。
func resolveCodexOutboundIdentityForAccountContext(_ context.Context, account *Account, overrideUA string) (codexOutboundIdentity, error) {
func resolveCodexOutboundIdentityForAccount(account *Account, overrideUA string) (codexOutboundIdentity, error) {
canonical := resolveCodexOutboundIdentity(overrideUA)
if overrideUA != "" {
return canonical, nil
Expand All @@ -301,20 +295,16 @@ func resolveCodexOutboundIdentityForAccountContext(_ context.Context, account *A

// enforceCodexIdentityHeadersForAccount 与 enforceCodexIdentityHeadersWithUA 语义相同,
// 但强制统一时使用账号级身份而不是全局规范身份,使同一账号的所有出站请求
// 表现为同一台机器上的同一个 Codex TUI。
// 表现为同一台机器上的同一个 Codex TUI。账号身份解析失败时返回该错误,请求头保持原样。
func enforceCodexIdentityHeadersForAccount(h http.Header, account *Account, overrideUA string) error {
return enforceCodexIdentityHeadersForAccountContext(context.Background(), h, account, overrideUA)
}

func enforceCodexIdentityHeadersForAccountContext(ctx context.Context, h http.Header, account *Account, overrideUA string) error {
if h == nil || h.Get("originator") == "" {
return nil
}
if !codexIdentityEnforcement.Load() {
pairCodexIdentityHeaders(h)
return nil
}
identity, err := resolveCodexOutboundIdentityForAccountContext(ctx, account, overrideUA)
identity, err := resolveCodexOutboundIdentityForAccount(account, overrideUA)
if err != nil {
return err
}
Expand Down
7 changes: 4 additions & 3 deletions backend/internal/service/openai_codex_identity_snapshot.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package service

import (
"context"
"fmt"
"net/http"
"strconv"
Expand Down Expand Up @@ -44,7 +43,7 @@ type codexIdentitySnapshot struct {
// resolveCodexIdentitySnapshot computes the identity and fingerprint state
// from the routed account and the credential source used for this attempt.
// It intentionally contains no credential, seed, cookie, or proxy URL data.
func resolveCodexIdentitySnapshotContext(ctx context.Context, routed, source *Account, overrideUA string) codexIdentitySnapshot {
func resolveCodexIdentitySnapshot(routed, source *Account, overrideUA string) codexIdentitySnapshot {
if source == nil {
source = routed
}
Expand Down Expand Up @@ -91,7 +90,9 @@ func resolveCodexIdentitySnapshotContext(ctx context.Context, routed, source *Ac
}()
}

identity, _ := resolveCodexOutboundIdentityForAccountContext(ctx, source, overrideUA)
// The snapshot only reports. The send path resolves the same identity for
// the request and is where a failure to build it stops the send.
identity, _ := resolveCodexOutboundIdentityForAccount(source, overrideUA)
snapshot.UserAgent = identity.userAgent
snapshot.Originator = identity.originator
snapshot.Version = identity.version
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/openai_codex_models_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1792,7 +1792,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc
identity := resolveCodexOutboundIdentity(overrideUA)
if !useAPIKeyUpstream {
overrideUA = codexAccountIdentityOverrideUA(credAccount)
identity, err = resolveCodexOutboundIdentityForAccountContext(ctx, credAccount, overrideUA)
identity, err = resolveCodexOutboundIdentityForAccount(credAccount, overrideUA)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading