Skip to content

Commit 0f24caf

Browse files
committed
feat(executor): implement identity obfuscation for Codex requests and responses
- Added `applyCodexIdentityConfuse*` functions for remapping request and response payloads and headers to enhance security. - Updated WebSocket and HTTP logic to handle identity state transformations seamlessly. - Introduced unit tests to verify remapping and restoration of identity-related fields.
1 parent 33983b6 commit 0f24caf

12 files changed

Lines changed: 397 additions & 91 deletions

config.example.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,21 @@ routing:
119119
strategy: "round-robin" # round-robin (default), fill-first
120120
# Enable universal session-sticky routing for all clients.
121121
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
122-
# X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
122+
# X-Session-ID, X-Amp-Thread-Id (Amp CLI),
123123
# X-Client-Request-Id (PI), conversation_id, or first few messages hash.
124124
# Automatic failover is always enabled when bound auth becomes unavailable.
125125
session-affinity: false # default: false
126126
# How long session-to-auth bindings are retained. Default: 1h
127127
session-affinity-ttl: "1h"
128128

129+
# Codex provider behavior.
130+
codex:
131+
# When true, and routing.strategy is fill-first or routing.session-affinity is true,
132+
# remap Codex prompt_cache_key and installation identity per selected auth.
133+
# Some superstitious users believe request tracking identifiers can be used
134+
# as evidence for TOS enforcement bans; this option only satisfies those odd concerns.
135+
identity-confuse: false
136+
129137
# When true, enable authentication for the WebSocket API (/v1/ws).
130138
ws-auth: true
131139

internal/config/codex_websocket_header_defaults_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,24 @@ codex-header-defaults:
3030
t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b")
3131
}
3232
}
33+
34+
func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) {
35+
dir := t.TempDir()
36+
configPath := filepath.Join(dir, "config.yaml")
37+
configYAML := []byte(`
38+
codex:
39+
identity-confuse: true
40+
`)
41+
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
42+
t.Fatalf("failed to write config: %v", err)
43+
}
44+
45+
cfg, err := LoadConfigOptional(configPath, false)
46+
if err != nil {
47+
t.Fatalf("LoadConfigOptional() error = %v", err)
48+
}
49+
50+
if !cfg.Codex.IdentityConfuse {
51+
t.Fatalf("IdentityConfuse = false, want true")
52+
}
53+
}

internal/config/config.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ type Config struct {
111111
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
112112
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
113113

114+
// Codex configures provider-wide Codex request behavior.
115+
Codex CodexConfig `yaml:"codex" json:"codex"`
116+
114117
// CodexHeaderDefaults configures fallback headers for Codex OAuth model requests.
115118
// These are used only when the client does not send its own headers.
116119
CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"`
@@ -172,6 +175,11 @@ type CodexHeaderDefaults struct {
172175
BetaFeatures string `yaml:"beta-features" json:"beta-features"`
173176
}
174177

178+
// CodexConfig configures provider-wide Codex request behavior.
179+
type CodexConfig struct {
180+
IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"`
181+
}
182+
175183
// TLSConfig holds HTTPS server settings.
176184
type TLSConfig struct {
177185
// Enable toggles HTTPS server mode.
@@ -229,7 +237,7 @@ type RoutingConfig struct {
229237

230238
// SessionAffinity enables universal session-sticky routing for all clients.
231239
// Session IDs are extracted from multiple sources:
232-
// metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex),
240+
// metadata.user_id (Claude Code session format), X-Session-ID,
233241
// X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id,
234242
// conversation_id, or message hash.
235243
// Automatic failover is always enabled when bound auth becomes unavailable.

internal/runtime/executor/codex_executor.go

Lines changed: 121 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,13 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
298298
reporter.SetTranslatedReasoningEffort(body, to.String())
299299

300300
url := strings.TrimSuffix(baseURL, "/") + "/responses"
301-
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
301+
var identityState codexIdentityConfuseState
302+
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
302303
if err != nil {
303304
return resp, err
304305
}
305306
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
307+
applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
306308
var authID, authLabel, authType, authValue string
307309
if auth != nil {
308310
authID = auth.ID
@@ -313,7 +315,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
313315
URL: url,
314316
Method: http.MethodPost,
315317
Headers: httpReq.Header.Clone(),
316-
Body: body,
318+
Body: upstreamBody,
317319
Provider: e.Identifier(),
318320
AuthID: authID,
319321
AuthLabel: authLabel,
@@ -335,6 +337,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
335337
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
336338
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
337339
b, _ := io.ReadAll(httpResp.Body)
340+
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
338341
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
339342
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
340343
err = newCodexStatusErr(httpResp.StatusCode, b)
@@ -345,9 +348,10 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
345348
helps.RecordAPIResponseError(ctx, e.cfg, err)
346349
return resp, err
347350
}
348-
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
351+
upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
352+
helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
349353

350-
lines := bytes.Split(data, []byte("\n"))
354+
lines := bytes.Split(upstreamData, []byte("\n"))
351355
outputItemsByIndex := make(map[int64][]byte)
352356
var outputItemsFallback [][]byte
353357
for _, line := range lines {
@@ -410,7 +414,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
410414
}
411415

412416
var param any
413-
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, completedData, &param)
417+
clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState)
418+
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientCompletedData, &param)
414419
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
415420
return resp, nil
416421
}
@@ -456,11 +461,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
456461
reporter.SetTranslatedReasoningEffort(body, to.String())
457462

458463
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
459-
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
464+
var identityState codexIdentityConfuseState
465+
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
460466
if err != nil {
461467
return resp, err
462468
}
463469
applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
470+
applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
464471
var authID, authLabel, authType, authValue string
465472
if auth != nil {
466473
authID = auth.ID
@@ -471,7 +478,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
471478
URL: url,
472479
Method: http.MethodPost,
473480
Headers: httpReq.Header.Clone(),
474-
Body: body,
481+
Body: upstreamBody,
475482
Provider: e.Identifier(),
476483
AuthID: authID,
477484
AuthLabel: authLabel,
@@ -493,6 +500,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
493500
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
494501
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
495502
b, _ := io.ReadAll(httpResp.Body)
503+
b = applyCodexIdentityConfuseResponsePayload(b, identityState)
496504
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
497505
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
498506
err = newCodexStatusErr(httpResp.StatusCode, b)
@@ -503,11 +511,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
503511
helps.RecordAPIResponseError(ctx, e.cfg, err)
504512
return resp, err
505513
}
506-
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
507-
reporter.Publish(ctx, helps.ParseOpenAIUsage(data))
514+
upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState)
515+
helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData)
516+
reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData))
508517
reporter.EnsurePublished(ctx)
509518
var param any
510-
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, &param)
519+
clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState)
520+
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientData, &param)
511521
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
512522
return resp, nil
513523
}
@@ -559,11 +569,13 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
559569
reporter.SetTranslatedReasoningEffort(body, to.String())
560570

561571
url := strings.TrimSuffix(baseURL, "/") + "/responses"
562-
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
572+
var identityState codexIdentityConfuseState
573+
httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body)
563574
if err != nil {
564575
return nil, err
565576
}
566577
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
578+
applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
567579
var authID, authLabel, authType, authValue string
568580
if auth != nil {
569581
authID = auth.ID
@@ -574,7 +586,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
574586
URL: url,
575587
Method: http.MethodPost,
576588
Headers: httpReq.Header.Clone(),
577-
Body: body,
589+
Body: upstreamBody,
578590
Provider: e.Identifier(),
579591
AuthID: authID,
580592
AuthLabel: authLabel,
@@ -599,6 +611,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
599611
helps.RecordAPIResponseError(ctx, e.cfg, readErr)
600612
return nil, readErr
601613
}
614+
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
602615
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
603616
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
604617
err = newCodexStatusErr(httpResp.StatusCode, data)
@@ -618,7 +631,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
618631
outputItemsByIndex := make(map[int64][]byte)
619632
var outputItemsFallback [][]byte
620633
for scanner.Scan() {
621-
line := scanner.Bytes()
634+
line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
622635
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
623636
translatedLine := bytes.Clone(line)
624637

@@ -646,6 +659,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
646659
}
647660
}
648661

662+
translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState)
649663
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, &param)
650664
for i := range chunks {
651665
select {
@@ -866,7 +880,12 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
866880
return auth, nil
867881
}
868882

869-
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) {
883+
type codexIdentityConfuseState struct {
884+
originalPromptCacheKey string
885+
promptCacheKey string
886+
}
887+
888+
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) {
870889
var cache helps.CodexCache
871890
if from == "claude" {
872891
userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id")
@@ -895,14 +914,98 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
895914
if cache.ID != "" {
896915
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
897916
}
917+
var identityState codexIdentityConfuseState
918+
rawJSON, identityState = applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, rawJSON)
919+
if identityState.promptCacheKey != "" {
920+
cache.ID = identityState.promptCacheKey
921+
}
898922
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON))
899923
if err != nil {
900-
return nil, err
924+
return nil, nil, codexIdentityConfuseState{}, err
901925
}
902-
if cache.ID != "" {
903-
httpReq.Header.Set("Session_id", cache.ID)
926+
return httpReq, rawJSON, identityState, nil
927+
}
928+
929+
func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth, userPayload []byte, rawJSON []byte) ([]byte, codexIdentityConfuseState) {
930+
if !codexIdentityConfuseEnabled(cfg) || auth == nil || strings.TrimSpace(auth.ID) == "" || len(rawJSON) == 0 {
931+
return rawJSON, codexIdentityConfuseState{}
932+
}
933+
934+
state := codexIdentityConfuseState{}
935+
if promptCacheKey := strings.TrimSpace(gjson.GetBytes(userPayload, "prompt_cache_key").String()); promptCacheKey != "" {
936+
state.originalPromptCacheKey = promptCacheKey
937+
state.promptCacheKey = codexIdentityConfuseUUID(auth.ID, "prompt-cache", promptCacheKey)
938+
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", state.promptCacheKey)
939+
}
940+
if installationID := strings.TrimSpace(gjson.GetBytes(userPayload, "client_metadata.x-codex-installation-id").String()); installationID != "" {
941+
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-installation-id", codexIdentityConfuseUUID(auth.ID, "installation", installationID))
904942
}
905-
return httpReq, nil
943+
if state.promptCacheKey != "" {
944+
if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
945+
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, state))
946+
}
947+
if windowID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-window-id").String()); windowID != "" {
948+
rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-window-id", state.promptCacheKey+":0")
949+
}
950+
}
951+
952+
return rawJSON, state
953+
}
954+
955+
func applyCodexIdentityConfuseHeaders(headers http.Header, state codexIdentityConfuseState) {
956+
if headers == nil || state.promptCacheKey == "" {
957+
return
958+
}
959+
960+
setHeaderCasePreserved(headers, "Session-Id", state.promptCacheKey)
961+
headers.Set("Conversation_id", state.promptCacheKey)
962+
headers.Set("X-Client-Request-Id", state.promptCacheKey)
963+
headers.Set("Thread-Id", state.promptCacheKey)
964+
headers.Set("X-Codex-Window-Id", state.promptCacheKey+":0")
965+
966+
if rawTurnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); rawTurnMetadata != "" {
967+
headers.Set("X-Codex-Turn-Metadata", applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata, state))
968+
}
969+
}
970+
971+
func applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata string, state codexIdentityConfuseState) string {
972+
updatedTurnMetadata := rawTurnMetadata
973+
if gjson.Get(rawTurnMetadata, "prompt_cache_key").Exists() {
974+
updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "prompt_cache_key", state.promptCacheKey)
975+
} else if state.originalPromptCacheKey != "" {
976+
updatedTurnMetadata = strings.ReplaceAll(updatedTurnMetadata, state.originalPromptCacheKey, state.promptCacheKey)
977+
}
978+
return updatedTurnMetadata
979+
}
980+
981+
func applyCodexIdentityConfuseResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
982+
return replaceCodexIdentityResponsePayload(payload, state.originalPromptCacheKey, state.promptCacheKey)
983+
}
984+
985+
func applyCodexIdentityExposeResponsePayload(payload []byte, state codexIdentityConfuseState) []byte {
986+
return replaceCodexIdentityResponsePayload(payload, state.promptCacheKey, state.originalPromptCacheKey)
987+
}
988+
989+
func replaceCodexIdentityResponsePayload(payload []byte, from string, to string) []byte {
990+
from = strings.TrimSpace(from)
991+
to = strings.TrimSpace(to)
992+
if len(payload) == 0 || from == "" || to == "" || from == to || !bytes.Contains(payload, []byte(from)) {
993+
return payload
994+
}
995+
return bytes.ReplaceAll(payload, []byte(from), []byte(to))
996+
}
997+
998+
func codexIdentityConfuseEnabled(cfg *config.Config) bool {
999+
if cfg == nil || !cfg.Codex.IdentityConfuse {
1000+
return false
1001+
}
1002+
strategy := strings.ToLower(strings.TrimSpace(cfg.Routing.Strategy))
1003+
return cfg.Routing.SessionAffinity || strategy == "fill-first" || strategy == "fillfirst" || strategy == "ff"
1004+
}
1005+
1006+
func codexIdentityConfuseUUID(authID string, kind string, value string) string {
1007+
name := strings.Join([]string{"cli-proxy-api", "codex", "identity-confuse", kind, strings.TrimSpace(authID), strings.TrimSpace(value)}, ":")
1008+
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String()
9061009
}
9071010

9081011
func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
@@ -923,10 +1026,6 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
9231026
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
9241027
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
9251028

926-
if strings.Contains(r.Header.Get("User-Agent"), "Mac OS") {
927-
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
928-
}
929-
9301029
if stream {
9311030
r.Header.Set("Accept", "text/event-stream")
9321031
} else {

0 commit comments

Comments
 (0)