Skip to content

Commit 18a3bd3

Browse files
authored
fix: 对齐 Claude/Codex UA 透传策略并完善 Claude cloaking (#205)
* fix: only passthrough user-agent for CLI clients * fix: avoid duplicate claude prompt injection and align client detection * fix: address PR review comments for UA and cloaking
1 parent 3bcd0c6 commit 18a3bd3

File tree

8 files changed

+266
-27
lines changed

8 files changed

+266
-27
lines changed

internal/adapter/provider/codex/adapter.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@ func (a *CodexAdapter) applyCodexHeaders(upstreamReq, clientReq *http.Request, a
699699
} else {
700700
ensureHeader(upstreamReq.Header, clientReq, "Session_id", uuid.NewString())
701701
}
702-
ensureHeader(upstreamReq.Header, clientReq, "User-Agent", CodexUserAgent)
702+
upstreamReq.Header.Set("User-Agent", resolveCodexUserAgent(clientReq))
703703
if hasAccessToken {
704704
ensureHeader(upstreamReq.Header, clientReq, "Originator", CodexOriginator)
705705
}
@@ -719,6 +719,20 @@ func ensureHeader(dst http.Header, clientReq *http.Request, key, defaultValue st
719719
dst.Set(key, defaultValue)
720720
}
721721

722+
func resolveCodexUserAgent(clientReq *http.Request) string {
723+
if clientReq != nil {
724+
if ua := strings.TrimSpace(clientReq.Header.Get("User-Agent")); isCodexCLIUserAgent(ua) {
725+
return ua
726+
}
727+
}
728+
return CodexUserAgent
729+
}
730+
731+
func isCodexCLIUserAgent(userAgent string) bool {
732+
ua := strings.ToLower(strings.TrimSpace(userAgent))
733+
return strings.HasPrefix(ua, "codex_cli_rs/") || strings.HasPrefix(ua, "codex-cli/")
734+
}
735+
722736
var codexFilteredHeaders = map[string]bool{
723737
// Hop-by-hop headers
724738
"connection": true,
@@ -730,6 +744,9 @@ var codexFilteredHeaders = map[string]bool{
730744
"host": true,
731745
"content-length": true,
732746

747+
// Explicitly controlled headers
748+
"user-agent": true,
749+
733750
// Proxy/forwarding headers (privacy protection)
734751
"x-forwarded-for": true,
735752
"x-forwarded-host": true,

internal/adapter/provider/codex/adapter_test.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func TestApplyCodexHeadersFiltersSensitiveAndPreservesUA(t *testing.T) {
4141
a := &CodexAdapter{}
4242
upstreamReq, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", nil)
4343
clientReq, _ := http.NewRequest("POST", "http://localhost/responses", nil)
44-
clientReq.Header.Set("User-Agent", "codex-cli-custom/1.2.3")
44+
clientReq.Header.Set("User-Agent", "codex-cli/1.2.3")
4545
clientReq.Header.Set("X-Forwarded-For", "1.2.3.4")
4646
clientReq.Header.Set("Traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")
4747
clientReq.Header.Set("X-Request-Id", "rid-1")
@@ -58,7 +58,7 @@ func TestApplyCodexHeadersFiltersSensitiveAndPreservesUA(t *testing.T) {
5858
if got := upstreamReq.Header.Get("X-Request-Id"); got != "" {
5959
t.Fatalf("expected X-Request-Id filtered, got %q", got)
6060
}
61-
if got := upstreamReq.Header.Get("User-Agent"); got != "codex-cli-custom/1.2.3" {
61+
if got := upstreamReq.Header.Get("User-Agent"); got != "codex-cli/1.2.3" {
6262
t.Fatalf("expected User-Agent passthrough, got %q", got)
6363
}
6464
if got := upstreamReq.Header.Get("X-Custom"); got != "ok" {
@@ -77,3 +77,31 @@ func TestIsCodexResponseCompletedLine(t *testing.T) {
7777
t.Fatal("expected invalid json line to be false")
7878
}
7979
}
80+
81+
func TestApplyCodexHeadersUsesDefaultUAForNonCLI(t *testing.T) {
82+
a := &CodexAdapter{}
83+
upstreamReq, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", nil)
84+
clientReq, _ := http.NewRequest("POST", "http://localhost/responses", nil)
85+
clientReq.Header.Set("User-Agent", "Mozilla/5.0")
86+
clientReq.Header.Set("X-Custom", "ok")
87+
88+
a.applyCodexHeaders(upstreamReq, clientReq, "token-1", "acct-1", true, "")
89+
90+
if got := upstreamReq.Header.Get("User-Agent"); got != CodexUserAgent {
91+
t.Fatalf("expected default Codex User-Agent for non-CLI client, got %q", got)
92+
}
93+
if got := upstreamReq.Header.Get("X-Custom"); got != "ok" {
94+
t.Fatalf("expected X-Custom passthrough, got %q", got)
95+
}
96+
}
97+
98+
func TestApplyCodexHeadersUsesDefaultUAWhenClientReqNil(t *testing.T) {
99+
a := &CodexAdapter{}
100+
upstreamReq, _ := http.NewRequest("POST", "https://chatgpt.com/backend-api/codex/responses", nil)
101+
102+
a.applyCodexHeaders(upstreamReq, nil, "token-1", "acct-1", true, "")
103+
104+
if got := upstreamReq.Header.Get("User-Agent"); got != CodexUserAgent {
105+
t.Fatalf("expected default Codex User-Agent when client request is nil, got %q", got)
106+
}
107+
}

internal/adapter/provider/custom/claude_body.go

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ const claudeToolPrefix = "proxy_"
2525
// userIDPattern matches Claude Code format: user_[64-hex]_account__session_[uuid-v4]
2626
var userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
2727

28+
// claudeCLIUserAgentPattern matches official Claude CLI user agent pattern.
29+
// Aligns with sub2api/claude-relay-service detection: claude-cli/x.y.z
30+
var claudeCLIUserAgentPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
31+
2832
// processClaudeRequestBody processes Claude request body before sending to upstream.
2933
// Following CLIProxyAPI order:
3034
// 1. applyCloaking (system prompt injection, fake user_id, sensitive word obfuscation)
@@ -74,10 +78,9 @@ func applyCloaking(body []byte, clientUserAgent string, model string, cloakCfg *
7478
return body
7579
}
7680

77-
// Skip system instructions for claude-3-5-haiku models (CLIProxyAPI behavior)
78-
if !strings.HasPrefix(model, "claude-3-5-haiku") {
79-
body = checkSystemInstructionsWithMode(body, strictMode)
80-
}
81+
// Always ensure Claude Code system prompt for cloaked requests.
82+
// This keeps messages-path requests compatible with strict Claude client validators.
83+
body = checkSystemInstructionsWithMode(body, strictMode)
8184

8285
// Inject fake user_id
8386
body = injectFakeUserID(body)
@@ -93,7 +96,7 @@ func applyCloaking(body []byte, clientUserAgent string, model string, cloakCfg *
9396

9497
// isClaudeCodeClient checks if the User-Agent indicates a Claude Code client.
9598
func isClaudeCodeClient(userAgent string) bool {
96-
return strings.HasPrefix(userAgent, "claude-cli")
99+
return claudeCLIUserAgentPattern.MatchString(strings.TrimSpace(userAgent))
97100
}
98101

99102
func isClaudeOAuthToken(apiKey string) bool {
@@ -273,7 +276,7 @@ func shouldCloak(cloakMode string, userAgent string) bool {
273276
case "never":
274277
return false
275278
default: // "auto" or empty
276-
return !strings.HasPrefix(userAgent, "claude-cli")
279+
return !isClaudeCodeClient(userAgent)
277280
}
278281
}
279282

@@ -336,30 +339,70 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) {
336339
// In strict mode, it replaces all user system messages.
337340
// In non-strict mode (default), it prepends to existing system messages.
338341
func checkSystemInstructionsWithMode(body []byte, strictMode bool) []byte {
339-
system := gjson.GetBytes(body, "system")
342+
if hasClaudeCodeSystemPrompt(body) {
343+
return body
344+
}
345+
340346
claudeCodeInstructions := `[{"type":"text","text":"` + claudeCodeSystemPrompt + `"}]`
341347

342348
if strictMode {
343349
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
344350
return body
345351
}
346352

353+
system := gjson.GetBytes(body, "system")
347354
if system.IsArray() {
348-
if gjson.GetBytes(body, "system.0.text").String() != claudeCodeSystemPrompt {
349-
system.ForEach(func(_, part gjson.Result) bool {
350-
if part.Get("type").String() == "text" {
351-
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw)
352-
}
353-
return true
354-
})
355-
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
356-
}
357-
} else {
355+
system.ForEach(func(_, part gjson.Result) bool {
356+
if part.Get("type").String() == "text" {
357+
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", part.Raw)
358+
}
359+
return true
360+
})
358361
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
362+
return body
359363
}
364+
365+
if system.Type == gjson.String && strings.TrimSpace(system.String()) != "" {
366+
existingBlock := `{"type":"text","text":` + system.Raw + `}`
367+
claudeCodeInstructions, _ = sjson.SetRaw(claudeCodeInstructions, "-1", existingBlock)
368+
}
369+
body, _ = sjson.SetRawBytes(body, "system", []byte(claudeCodeInstructions))
360370
return body
361371
}
362372

373+
func hasClaudeCodeSystemPrompt(body []byte) bool {
374+
system := gjson.GetBytes(body, "system")
375+
if !system.Exists() {
376+
return false
377+
}
378+
379+
if system.IsArray() {
380+
found := false
381+
system.ForEach(func(_, part gjson.Result) bool {
382+
if strings.TrimSpace(part.Get("text").String()) == claudeCodeSystemPrompt {
383+
found = true
384+
return false
385+
}
386+
if part.Type == gjson.String && strings.TrimSpace(part.String()) == claudeCodeSystemPrompt {
387+
found = true
388+
return false
389+
}
390+
return true
391+
})
392+
return found
393+
}
394+
395+
if system.Type == gjson.String {
396+
return strings.TrimSpace(system.String()) == claudeCodeSystemPrompt
397+
}
398+
399+
if system.IsObject() {
400+
return strings.TrimSpace(system.Get("text").String()) == claudeCodeSystemPrompt
401+
}
402+
403+
return false
404+
}
405+
363406
// ===== Sensitive word obfuscation (CLIProxyAPI-aligned) =====
364407

365408
// zeroWidthSpace is the Unicode zero-width space character used for obfuscation.

internal/adapter/provider/custom/claude_body_test.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,24 @@ func TestShouldCloakModes(t *testing.T) {
121121
if shouldCloak("never", "curl/7.68.0") {
122122
t.Error("never mode should cloak none")
123123
}
124+
if !shouldCloak("", "claude-cli/dev") {
125+
t.Error("default mode should cloak non-official claude-cli UA")
126+
}
127+
if shouldCloak("", "Claude-CLI/2.1.17 (external, cli)") {
128+
t.Error("default mode should not cloak case-insensitive official claude-cli UA")
129+
}
124130
}
125131

126-
func TestSkipSystemInjectionForHaiku(t *testing.T) {
132+
func TestSystemInjectionForHaikuWhenCloaked(t *testing.T) {
127133
body := []byte(`{"model":"claude-3-5-haiku-20241022","messages":[{"role":"user","content":"hello"}]}`)
128134

129135
result := applyCloaking(body, "curl/7.68.0", "claude-3-5-haiku-20241022", nil)
130136

131-
if gjson.GetBytes(result, "system").Exists() {
132-
t.Error("system prompt should be skipped for claude-3-5-haiku models")
137+
if !gjson.GetBytes(result, "system").Exists() {
138+
t.Error("system prompt should be injected for cloaked haiku requests")
133139
}
134140
if !gjson.GetBytes(result, "metadata.user_id").Exists() {
135-
t.Error("user_id should still be injected for haiku models")
141+
t.Error("user_id should be injected for haiku models")
136142
}
137143
}
138144

@@ -367,7 +373,7 @@ func TestNoDuplicateSystemPromptInjection(t *testing.T) {
367373
body := []byte(`{
368374
"model":"claude-3-5-sonnet",
369375
"messages":[{"role":"user","content":"hello"}],
370-
"system":[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."},{"type":"text","text":"Additional instructions"}]
376+
"system":[{"type":"text","text":"Additional instructions"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}]
371377
}`)
372378

373379
result := injectClaudeCodeSystemPrompt(body)
@@ -405,3 +411,20 @@ func TestEnsureMinThinkingBudget(t *testing.T) {
405411
t.Fatalf("disabled thinking budget_tokens = %d, want 100 (unchanged)", got)
406412
}
407413
}
414+
415+
func TestCloakingPreservesSystemStringInNonStrictMode(t *testing.T) {
416+
body := []byte(`{
417+
"model":"claude-3-5-sonnet",
418+
"system":"Keep this instruction",
419+
"messages":[{"role":"user","content":"hello"}]
420+
}`)
421+
cfg := &domain.ProviderConfigCustomCloak{Mode: "always", StrictMode: false}
422+
423+
result := applyCloaking(body, "curl/7.68.0", "claude-3-5-sonnet", cfg)
424+
if got := gjson.GetBytes(result, "system.0.text").String(); got != claudeCodeSystemPrompt {
425+
t.Fatalf("expected Claude Code prompt prepended, got %q", got)
426+
}
427+
if got := gjson.GetBytes(result, "system.1.text").String(); got != "Keep this instruction" {
428+
t.Fatalf("expected original system string preserved, got %q", got)
429+
}
430+
}

internal/adapter/provider/custom/claude_headers.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,16 @@ func applyClaudeHeaders(req *http.Request, clientReq *http.Request, apiKey strin
8080
ensureHeader(req.Header, clientHeaders, "X-Stainless-Arch", "arm64")
8181
ensureHeader(req.Header, clientHeaders, "X-Stainless-Os", "MacOS")
8282
ensureHeader(req.Header, clientHeaders, "X-Stainless-Timeout", "60")
83-
ensureHeader(req.Header, clientHeaders, "User-Agent", defaultClaudeUserAgent)
83+
84+
clientUA := ""
85+
if clientHeaders != nil {
86+
clientUA = strings.TrimSpace(clientHeaders.Get("User-Agent"))
87+
}
88+
if isClaudeCodeClient(clientUA) {
89+
req.Header.Set("User-Agent", clientUA)
90+
} else {
91+
req.Header.Set("User-Agent", defaultClaudeUserAgent)
92+
}
8493

8594
// 6. Set connection and encoding headers (always override)
8695
req.Header.Set("Connection", "keep-alive")

internal/adapter/provider/custom/claude_headers_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package custom
22

33
import (
44
"net/http"
5+
"regexp"
56
"strings"
67
"testing"
8+
9+
"github.com/tidwall/gjson"
710
)
811

912
func TestApplyClaudeHeadersAccept(t *testing.T) {
@@ -95,3 +98,65 @@ func TestApplyClaudeHeadersDefaults(t *testing.T) {
9598
t.Error("X-Stainless-Runtime should be set")
9699
}
97100
}
101+
102+
func TestApplyClaudeHeadersUserAgentPassthroughOnlyForCLI(t *testing.T) {
103+
cliReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
104+
cliClientReq, _ := http.NewRequest("POST", "https://example.com", nil)
105+
cliClientReq.Header.Set("User-Agent", "claude-cli/2.1.23 (external, cli)")
106+
107+
applyClaudeHeaders(cliReq, cliClientReq, "sk-test", true, nil, true)
108+
if got := cliReq.Header.Get("User-Agent"); got != "claude-cli/2.1.23 (external, cli)" {
109+
t.Fatalf("expected CLI User-Agent passthrough, got %q", got)
110+
}
111+
112+
nonCLIReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
113+
nonCLIClientReq, _ := http.NewRequest("POST", "https://example.com", nil)
114+
nonCLIClientReq.Header.Set("User-Agent", "Mozilla/5.0")
115+
116+
applyClaudeHeaders(nonCLIReq, nonCLIClientReq, "sk-test", true, nil, true)
117+
if got := nonCLIReq.Header.Get("User-Agent"); got != defaultClaudeUserAgent {
118+
t.Fatalf("expected default User-Agent for non-CLI client, got %q", got)
119+
}
120+
121+
nonOfficialReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
122+
nonOfficialClientReq, _ := http.NewRequest("POST", "https://example.com", nil)
123+
nonOfficialClientReq.Header.Set("User-Agent", "claude-cli/dev")
124+
125+
applyClaudeHeaders(nonOfficialReq, nonOfficialClientReq, "sk-test", true, nil, true)
126+
if got := nonOfficialReq.Header.Get("User-Agent"); got != defaultClaudeUserAgent {
127+
t.Fatalf("expected default User-Agent for non-official CLI UA, got %q", got)
128+
}
129+
}
130+
131+
func TestCloakingBuildsSub2apiCompatibleClaudeShape(t *testing.T) {
132+
clientReq, _ := http.NewRequest("POST", "https://example.com/v1/messages", nil)
133+
clientReq.Header.Set("User-Agent", "curl/8.0.0")
134+
135+
body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hello"}]}`)
136+
processedBody, extraBetas := processClaudeRequestBody(body, clientReq.Header.Get("User-Agent"), nil)
137+
138+
upstreamReq, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
139+
applyClaudeHeaders(upstreamReq, clientReq, "sk-test", true, extraBetas, true)
140+
141+
uaPattern := regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
142+
if got := upstreamReq.Header.Get("User-Agent"); !uaPattern.MatchString(got) {
143+
t.Fatalf("expected sub2api-compatible User-Agent, got %q", got)
144+
}
145+
146+
for _, key := range []string{"X-App", "Anthropic-Beta", "Anthropic-Version"} {
147+
if strings.TrimSpace(upstreamReq.Header.Get(key)) == "" {
148+
t.Fatalf("expected %s to be set", key)
149+
}
150+
}
151+
152+
userID := gjson.GetBytes(processedBody, "metadata.user_id").String()
153+
userIDPattern := regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
154+
if !userIDPattern.MatchString(userID) {
155+
t.Fatalf("expected sub2api-compatible metadata.user_id, got %q", userID)
156+
}
157+
158+
systemText := gjson.GetBytes(processedBody, "system.0.text").String()
159+
if !strings.Contains(systemText, "Claude Code, Anthropic's official CLI for Claude") {
160+
t.Fatalf("expected cloaked system prompt, got %q", systemText)
161+
}
162+
}

0 commit comments

Comments
 (0)