mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-24 16:45:45 +08:00
feat(claude): strip Claude Code identity on API keys with cloak mode never or uncloak
- Strip incoming Claude Code billing attribution, CLI identity system blocks, metadata user IDs, and diagnostics when a non-CLI API key explicitly configures cloak mode as never or uncloak. - Remove Claude Code CLI headers, beta flags, and user-agent strings on uncloaked API key requests while preserving capability betas and credential header overrides. - Keep global disable-claude-cloak-mode passthrough behavior unchanged for credentials without explicit per-key cloak configuration. Closes: #6762
This commit is contained in:
@@ -728,7 +728,7 @@ upstream:
|
||||
# cloak: # optional: explicitly enable request cloaking for non-Claude-Code clients
|
||||
# mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code
|
||||
# # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through
|
||||
# # "never": never apply cloaking
|
||||
# # "never" / "uncloak": never apply cloaking; on non-CLI API keys, also strip incoming Claude Code identity/billing attribution
|
||||
# # This "cloak" block applies to this api-keys.claude key entry only. For Claude OAuth
|
||||
# # credentials, set the same options in the auth/token JSON file via "cloak_mode" /
|
||||
# # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id".
|
||||
|
||||
@@ -564,11 +564,11 @@ type PayloadModelRule struct {
|
||||
// CloakConfig configures request cloaking for non-Claude-Code clients.
|
||||
// Cloaking disguises API requests to appear as originating from the official Claude Code CLI.
|
||||
type CloakConfig struct {
|
||||
// Mode controls cloaking behavior: "auto" (default), "always", or "never".
|
||||
// Mode controls cloaking behavior: "auto" (default), "always", "never", or "uncloak".
|
||||
// Supplying this CloakConfig explicitly enables cloaking for an unprofiled API key.
|
||||
// - "auto": cloak unless strong request signals identify a verified native entrypoint
|
||||
// - "always": cloak every unconfirmed client; confirmed native Claude Code remains passthrough
|
||||
// - "never": never apply cloaking
|
||||
// - "never" / "uncloak": never apply cloaking; on non-CLI API keys, also strip incoming Claude Code identity and billing attribution
|
||||
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
|
||||
|
||||
// StrictMode controls how caller system prompts are handled when cloaking.
|
||||
|
||||
@@ -1584,6 +1584,7 @@ type claudeWirePolicy struct {
|
||||
ProfileClaudeCodeCLI bool // request fingerprint looks like Claude Code CLI
|
||||
ConfirmedClaudeCode bool
|
||||
Cloak bool
|
||||
Uncloak bool
|
||||
}
|
||||
|
||||
type claudeCloakSettings struct {
|
||||
@@ -1592,6 +1593,79 @@ type claudeCloakSettings struct {
|
||||
cacheUserID bool
|
||||
}
|
||||
|
||||
func isClaudeCodeIdentitySystemText(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
return trimmed == claudeCodeCLIIdentity ||
|
||||
trimmed == "You are a Claude agent, built on Anthropic's Claude Agent SDK."
|
||||
}
|
||||
|
||||
func stripClaudeCodeSystemBlocks(payload []byte) []byte {
|
||||
payload = util.StripClaudeCodeAttributionSystem(payload)
|
||||
system := gjson.GetBytes(payload, "system")
|
||||
if !system.Exists() {
|
||||
return payload
|
||||
}
|
||||
if system.Type == gjson.String {
|
||||
if !isClaudeCodeIdentitySystemText(system.String()) {
|
||||
return payload
|
||||
}
|
||||
if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil {
|
||||
return updated
|
||||
}
|
||||
return payload
|
||||
}
|
||||
if !system.IsArray() {
|
||||
return payload
|
||||
}
|
||||
kept := make([]string, 0, len(system.Array()))
|
||||
removed := false
|
||||
system.ForEach(func(_, block gjson.Result) bool {
|
||||
if block.Get("type").String() == "text" && isClaudeCodeIdentitySystemText(block.Get("text").String()) {
|
||||
removed = true
|
||||
return true
|
||||
}
|
||||
if block.Raw != "" {
|
||||
kept = append(kept, block.Raw)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !removed {
|
||||
return payload
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil {
|
||||
return updated
|
||||
}
|
||||
return payload
|
||||
}
|
||||
if updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]")); errSet == nil {
|
||||
return updated
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func uncloakClaudeCodePayload(payload []byte, confirmedClaudeCode bool) []byte {
|
||||
payload = stripClaudeCodeSystemBlocks(payload)
|
||||
if metadata := gjson.GetBytes(payload, "metadata"); metadata.Exists() && metadata.IsObject() {
|
||||
if userID := metadata.Get("user_id"); userID.Exists() && (confirmedClaudeCode || helps.IsValidUserID(userID.String())) {
|
||||
if updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id"); errDelete == nil {
|
||||
payload = updated
|
||||
if remaining := gjson.GetBytes(payload, "metadata"); remaining.Exists() && remaining.IsObject() && len(remaining.Map()) == 0 {
|
||||
if updatedMeta, errDeleteMeta := sjson.DeleteBytes(payload, "metadata"); errDeleteMeta == nil {
|
||||
payload = updatedMeta
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if diag := gjson.GetBytes(payload, "diagnostics"); diag.Exists() && (confirmedClaudeCode || diag.Get("previous_message_id").Exists()) {
|
||||
if updated, errDelete := sjson.DeleteBytes(payload, "diagnostics"); errDelete == nil {
|
||||
payload = updated
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool) (claudeWirePolicy, claudeCloakSettings) {
|
||||
if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey {
|
||||
cfg = cfg.ForAPIKey()
|
||||
@@ -1608,12 +1682,14 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey
|
||||
sensitiveWords: attrWords,
|
||||
cacheUserID: attrCache,
|
||||
}
|
||||
explicitMode := strings.ToLower(strings.TrimSpace(attrMode))
|
||||
if attrMode != "" {
|
||||
cloakMode = attrMode
|
||||
}
|
||||
if cloakCfg != nil {
|
||||
if mode := strings.TrimSpace(cloakCfg.Mode); mode != "" {
|
||||
cloakMode = mode
|
||||
explicitMode = strings.ToLower(mode)
|
||||
}
|
||||
if cloakCfg.StrictMode {
|
||||
settings.strictMode = true
|
||||
@@ -1628,23 +1704,25 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey
|
||||
|
||||
fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey)
|
||||
cloakConfigured := cloakCfg != nil || attrMode != "" || attrStrict || len(attrWords) > 0 || attrCache
|
||||
trimmedMode := strings.ToLower(strings.TrimSpace(cloakMode))
|
||||
uncloak := !fp.ProfileClaudeCodeCLI && (explicitMode == "never" || explicitMode == "uncloak")
|
||||
policy := claudeWirePolicy{
|
||||
OAuth: fp.AuthIsOAuthToken,
|
||||
ProfileClaudeCodeCLI: fp.ProfileClaudeCodeCLI,
|
||||
ConfirmedClaudeCode: confirmedClaudeCode,
|
||||
Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode,
|
||||
Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode && !uncloak,
|
||||
Uncloak: uncloak,
|
||||
}
|
||||
if confirmedClaudeCode {
|
||||
// Native Claude Code is always a passthrough client. An operator-level
|
||||
// "always" mode may cloak unknown callers, but must not overwrite a
|
||||
// strongly confirmed CLI, sdk-cli, or claude-vscode fingerprint.
|
||||
// Native Claude Code is always a passthrough client unless the
|
||||
// credential explicitly opts out of Claude Code identity via never/uncloak.
|
||||
policy.Cloak = false
|
||||
return policy, settings
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cloakMode)) {
|
||||
switch trimmedMode {
|
||||
case "always":
|
||||
policy.Cloak = true
|
||||
case "never":
|
||||
case "never", "uncloak":
|
||||
policy.Cloak = false
|
||||
default:
|
||||
// Auto applies the CLI cloak only to real Claude OAuth credentials,
|
||||
@@ -1681,6 +1759,9 @@ func applyCloakingInternal(
|
||||
) ([]byte, bool, error) {
|
||||
policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode)
|
||||
if !policy.Cloak {
|
||||
if policy.Uncloak {
|
||||
payload = uncloakClaudeCodePayload(payload, confirmedClaudeCode)
|
||||
}
|
||||
return payload, false, nil
|
||||
}
|
||||
isExplicit := (len(explicitCacheMode) > 0 && explicitCacheMode[0]) || isExplicitPromptCacheModeContext(ctx)
|
||||
|
||||
@@ -1144,7 +1144,7 @@ func claudeCredentialUsesOAuth(auth *cliproxyauth.Auth, apiKey string) bool {
|
||||
return !hasAPIKeyAttr
|
||||
}
|
||||
|
||||
func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode bool) {
|
||||
func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode, uncloak bool) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
@@ -1163,6 +1163,21 @@ func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCod
|
||||
if !confirmedClaudeCode && (strings.HasPrefix(lowerName, "x-claude-code-") || strings.HasPrefix(lowerName, "x-claude-remote-")) {
|
||||
continue
|
||||
}
|
||||
if uncloak {
|
||||
if lowerName == "x-app" ||
|
||||
lowerName == "x-client-request-id" ||
|
||||
lowerName == "x-client-app" ||
|
||||
lowerName == "x-anthropic-additional-protection" ||
|
||||
lowerName == "anthropic-dangerous-direct-browser-access" ||
|
||||
strings.HasPrefix(lowerName, "x-stainless-") ||
|
||||
strings.HasPrefix(lowerName, "x-claude-code-") ||
|
||||
strings.HasPrefix(lowerName, "x-claude-remote-") {
|
||||
continue
|
||||
}
|
||||
if lowerName == "user-agent" && len(values) > 0 && helps.IsClaudeCodeUserAgent(values[0]) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst.Del(name)
|
||||
for _, value := range values {
|
||||
dst.Add(name, value)
|
||||
@@ -1221,6 +1236,11 @@ func applyClaudeHeadersWithNativeProfile(
|
||||
useAPIKey := !credentialUsesBearer
|
||||
fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey)
|
||||
wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode)
|
||||
if wirePolicy.Uncloak {
|
||||
confirmedClaudeCode = false
|
||||
helperProfile = false
|
||||
sessionIDs = nil
|
||||
}
|
||||
applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak
|
||||
preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode
|
||||
useOAuthBetas := fp.UseOAuthBetas
|
||||
@@ -1356,6 +1376,21 @@ func applyClaudeHeadersWithNativeProfile(
|
||||
}
|
||||
}
|
||||
applyBetaHeader := func() {
|
||||
if wirePolicy.Uncloak {
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeCodeBeta)
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeOAuthBeta)
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeRedactThinkingBeta)
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeAFKModeBeta)
|
||||
if !gjson.GetBytes(body, "diagnostics").Exists() {
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeCacheDiagnosisBeta)
|
||||
}
|
||||
if !gjson.GetBytes(body, "fallback_credit_token").Exists() {
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeFallbackCreditBeta)
|
||||
}
|
||||
if !helps.ClaudePayloadHas1hTTL(body) {
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta)
|
||||
}
|
||||
}
|
||||
// Enforce strict native Claude Code 2.1.280 model & turn beta gating:
|
||||
if !claudeRequestSupportsEffort(body, nil) {
|
||||
baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta)
|
||||
@@ -1412,7 +1447,7 @@ func applyClaudeHeadersWithNativeProfile(
|
||||
defaultAccept = "text/event-stream"
|
||||
defaultAcceptEncoding = "identity"
|
||||
}
|
||||
copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode)
|
||||
copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode, wirePolicy.Uncloak)
|
||||
if hd.ClientRequestID == config.ClaudeClientRequestIDOff {
|
||||
r.Header.Del("X-Client-Request-Id")
|
||||
}
|
||||
@@ -1423,7 +1458,11 @@ func applyClaudeHeadersWithNativeProfile(
|
||||
// sent none must not fall through to Go's transport default
|
||||
// ("Go-http-client/1.1"), which upstreams read as a bot signature. Identify
|
||||
// as CPA instead: honest about the hop, and not a fabricated client.
|
||||
misc.EnsureHeader(r.Header, incomingHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version)
|
||||
uaSourceHeaders := incomingHeaders
|
||||
if wirePolicy.Uncloak && helps.IsClaudeCodeUserAgent(helps.HeaderValueCaseInsensitive(incomingHeaders, "User-Agent")) {
|
||||
uaSourceHeaders = nil
|
||||
}
|
||||
misc.EnsureHeader(r.Header, uaSourceHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version)
|
||||
applyBetaHeader()
|
||||
sessionID := ""
|
||||
for _, candidate := range sessionIDs {
|
||||
|
||||
@@ -200,6 +200,8 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
|
||||
if len(settings.sensitiveWords) > 0 {
|
||||
body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords))
|
||||
}
|
||||
} else if policy.Uncloak {
|
||||
body = uncloakClaudeCodePayload(body, confirmedClaudeCode)
|
||||
}
|
||||
|
||||
// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
|
||||
|
||||
@@ -1243,3 +1243,294 @@ func claudeFingerprintHeaderValue(headers http.Header, name string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestClaudeExecutor_OfficialAPIKeyCloakNeverStripsClaudeCodeIdentity(t *testing.T) {
|
||||
const sessionID = "11111111-2222-4333-8444-555555555555"
|
||||
const userID = `{"device_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","account_uuid":"11111111-2222-4333-8444-555555555555","session_id":"11111111-2222-4333-8444-555555555555"}`
|
||||
const billing = "x-anthropic-billing-header: cc_version=2.1.280.d7b; cc_entrypoint=cli; cch=00000; cc_turn_origin=human;"
|
||||
incoming := http.Header{
|
||||
"User-Agent": {"claude-cli/2.1.280 (external, cli)"},
|
||||
"X-App": {"cli"},
|
||||
"Anthropic-Beta": {"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,cache-diagnosis-2026-04-07"},
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": {"true"},
|
||||
"X-Claude-Code-Session-Id": {sessionID},
|
||||
"X-Claude-Code-Request-Class": {"main"},
|
||||
"X-Stainless-Package-Version": {"0.112.1"},
|
||||
"X-Stainless-Runtime-Version": {"v26.3.0"},
|
||||
"X-Stainless-Os": {"MacOS"},
|
||||
"X-Stainless-Arch": {"arm64"},
|
||||
}
|
||||
payload := []byte(
|
||||
`{"model":"claude-opus-4-6","max_tokens":64,"system":[{"type":"text","text":"` + billing + `"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"You are an interactive CLI tool that helps users with software engineering tasks.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":` + `"` + strings.ReplaceAll(userID, `"`, `\"`) + `"` + `},"diagnostics":{"previous_message_id":null},"thinking":{"type":"adaptive"}}`,
|
||||
)
|
||||
if detection := helps.DetectClaudeCodeRequest(incoming, payload, false); !detection.Confirmed {
|
||||
t.Fatal("fixture must be classified as a confirmed native Claude Code request")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mode string
|
||||
customUA string
|
||||
stream bool
|
||||
countTokens bool
|
||||
wantUserAgent string
|
||||
wantBetaSubstr string
|
||||
}{
|
||||
{
|
||||
name: "execute cloak never default user-agent",
|
||||
mode: "never",
|
||||
wantUserAgent: "CLIProxyAPI/",
|
||||
wantBetaSubstr: "interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "execute cloak never custom user-agent override",
|
||||
mode: "never",
|
||||
customUA: "my-api-client/1.0",
|
||||
wantUserAgent: "my-api-client/1.0",
|
||||
wantBetaSubstr: "interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "stream cloak never custom user-agent override",
|
||||
mode: "never",
|
||||
customUA: "my-api-client/1.0",
|
||||
stream: true,
|
||||
wantUserAgent: "my-api-client/1.0",
|
||||
wantBetaSubstr: "interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "execute cloak uncloak mode",
|
||||
mode: "uncloak",
|
||||
customUA: "my-api-client/1.0",
|
||||
wantUserAgent: "my-api-client/1.0",
|
||||
wantBetaSubstr: "interleaved-thinking-2025-05-14",
|
||||
},
|
||||
{
|
||||
name: "count_tokens cloak never",
|
||||
mode: "never",
|
||||
customUA: "my-api-client/1.0",
|
||||
countTokens: true,
|
||||
wantUserAgent: "my-api-client/1.0",
|
||||
wantBetaSubstr: "token-counting-2024-11-01",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var seenBody []byte
|
||||
var seenHeaders http.Header
|
||||
transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seenBody, _ = io.ReadAll(req.Body)
|
||||
seenHeaders = req.Header.Clone()
|
||||
if tc.countTokens {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"input_tokens":12}`)),
|
||||
}, nil
|
||||
}
|
||||
if tc.stream {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
||||
)),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"msg_1","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
)),
|
||||
}, nil
|
||||
})
|
||||
ctx := context.WithValue(
|
||||
context.Background(),
|
||||
"cliproxy.roundtripper",
|
||||
http.RoundTripper(transport),
|
||||
)
|
||||
|
||||
var customHeaders map[string]string
|
||||
attrs := map[string]string{
|
||||
"api_key": "sk-ant-api03-promotional-key",
|
||||
}
|
||||
if tc.customUA != "" {
|
||||
customHeaders = map[string]string{"User-Agent": tc.customUA}
|
||||
attrs["header:User-Agent"] = tc.customUA
|
||||
}
|
||||
cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
|
||||
APIKey: "sk-ant-api03-promotional-key",
|
||||
Cloak: &config.CloakConfig{Mode: tc.mode},
|
||||
Headers: customHeaders,
|
||||
}}}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "official-promotional-key",
|
||||
Attributes: attrs,
|
||||
}
|
||||
exec := NewClaudeExecutor(cfg)
|
||||
req := cliproxyexecutor.Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Payload: payload,
|
||||
}
|
||||
opts := cliproxyexecutor.Options{
|
||||
Stream: tc.stream,
|
||||
SourceFormat: sdktranslator.FormatClaude,
|
||||
OriginalRequest: payload,
|
||||
Headers: incoming.Clone(),
|
||||
}
|
||||
|
||||
if tc.countTokens {
|
||||
if _, errCount := exec.CountTokens(ctx, auth, req, opts); errCount != nil {
|
||||
t.Fatalf("CountTokens() error = %v", errCount)
|
||||
}
|
||||
} else if tc.stream {
|
||||
res, errStream := exec.ExecuteStream(ctx, auth, req, opts)
|
||||
if errStream != nil {
|
||||
t.Fatalf("ExecuteStream() error = %v", errStream)
|
||||
}
|
||||
for chunk := range res.Chunks {
|
||||
if chunk.Err != nil {
|
||||
t.Fatalf("stream chunk error = %v", chunk.Err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, errExec := exec.Execute(ctx, auth, req, opts); errExec != nil {
|
||||
t.Fatalf("Execute() error = %v", errExec)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") {
|
||||
t.Fatalf("upstream body still contains Claude Code billing header: %s", seenBody)
|
||||
}
|
||||
if strings.Contains(string(seenBody), claudeCodeCLIIdentity) {
|
||||
t.Fatalf("upstream body still contains Claude Code CLI identity block: %s", seenBody)
|
||||
}
|
||||
if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 {
|
||||
t.Fatalf("system block count = %d, want 1 preserved instruction block: %s", got, seenBody)
|
||||
}
|
||||
if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "You are an interactive CLI tool that helps users with software engineering tasks." {
|
||||
t.Fatalf("system.0.text = %q, want preserved caller instructions", got)
|
||||
}
|
||||
if gjson.GetBytes(seenBody, "metadata").Exists() {
|
||||
t.Fatalf("metadata = %s, want Claude Code metadata stripped", gjson.GetBytes(seenBody, "metadata").Raw)
|
||||
}
|
||||
if gjson.GetBytes(seenBody, "diagnostics").Exists() {
|
||||
t.Fatalf("diagnostics = %s, want Claude Code diagnostics stripped", gjson.GetBytes(seenBody, "diagnostics").Raw)
|
||||
}
|
||||
|
||||
ua := claudeFingerprintHeaderValue(seenHeaders, "User-Agent")
|
||||
if strings.HasPrefix(strings.ToLower(ua), "claude-cli/") {
|
||||
t.Fatalf("User-Agent = %q, must not forward claude-cli user-agent", ua)
|
||||
}
|
||||
if !strings.HasPrefix(ua, tc.wantUserAgent) {
|
||||
t.Fatalf("User-Agent = %q, want prefix %q", ua, tc.wantUserAgent)
|
||||
}
|
||||
for _, forbiddenHdr := range []string{
|
||||
"X-App",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access",
|
||||
"X-Claude-Code-Session-Id",
|
||||
"X-Claude-Code-Request-Class",
|
||||
"X-Stainless-Runtime",
|
||||
"X-Stainless-Lang",
|
||||
"X-Stainless-Package-Version",
|
||||
} {
|
||||
if got := claudeFingerprintHeaderValue(seenHeaders, forbiddenHdr); got != "" {
|
||||
t.Fatalf("header %s = %q, want stripped", forbiddenHdr, got)
|
||||
}
|
||||
}
|
||||
|
||||
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
|
||||
for _, forbiddenBeta := range []string{
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
"redact-thinking-2026-02-12",
|
||||
"cache-diagnosis-2026-04-07",
|
||||
} {
|
||||
if strings.Contains(betas, forbiddenBeta) {
|
||||
t.Fatalf("Anthropic-Beta = %q, want %q stripped", betas, forbiddenBeta)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(betas, tc.wantBetaSubstr) {
|
||||
t.Fatalf("Anthropic-Beta = %q, want capability beta %q preserved", betas, tc.wantBetaSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeExecutor_GlobalDisableClaudeCloakModePreservesConfirmedCallerIdentityWhenKeyUnconfigured(t *testing.T) {
|
||||
const sessionID = "11111111-2222-4333-8444-555555555555"
|
||||
const userID = `{"device_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","account_uuid":"11111111-2222-4333-8444-555555555555","session_id":"11111111-2222-4333-8444-555555555555"}`
|
||||
const billing = "x-anthropic-billing-header: cc_version=2.1.280.d7b; cc_entrypoint=cli; cch=00000; cc_turn_origin=human;"
|
||||
incoming := http.Header{
|
||||
"User-Agent": {"claude-cli/2.1.280 (external, cli)"},
|
||||
"X-App": {"cli"},
|
||||
"Anthropic-Beta": {"claude-code-20250219,interleaved-thinking-2025-05-14"},
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": {"true"},
|
||||
"X-Claude-Code-Session-Id": {sessionID},
|
||||
"X-Stainless-Package-Version": {"0.112.1"},
|
||||
"X-Stainless-Runtime-Version": {"v26.3.0"},
|
||||
"X-Stainless-Os": {"MacOS"},
|
||||
"X-Stainless-Arch": {"arm64"},
|
||||
}
|
||||
payload := []byte(
|
||||
`{"model":"claude-opus-4-6","max_tokens":64,"system":[{"type":"text","text":"` + billing + `"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":` + `"` + strings.ReplaceAll(userID, `"`, `\"`) + `"` + `}}`,
|
||||
)
|
||||
|
||||
var seenBody []byte
|
||||
var seenHeaders http.Header
|
||||
transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seenBody, _ = io.ReadAll(req.Body)
|
||||
seenHeaders = req.Header.Clone()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"msg_1","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
)),
|
||||
}, nil
|
||||
})
|
||||
ctx := context.WithValue(
|
||||
context.Background(),
|
||||
"cliproxy.roundtripper",
|
||||
http.RoundTripper(transport),
|
||||
)
|
||||
|
||||
cfg := &config.Config{
|
||||
DisableClaudeCloakMode: true,
|
||||
ClaudeKey: []config.ClaudeKey{{
|
||||
APIKey: "sk-ant-api03-global-disable-passthrough",
|
||||
}},
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "official-global-disable-passthrough",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "sk-ant-api03-global-disable-passthrough",
|
||||
},
|
||||
}
|
||||
|
||||
if _, errExec := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Payload: payload,
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FormatClaude,
|
||||
OriginalRequest: payload,
|
||||
Headers: incoming,
|
||||
}); errExec != nil {
|
||||
t.Fatalf("Execute() error = %v", errExec)
|
||||
}
|
||||
|
||||
if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != billing {
|
||||
t.Fatalf("system.0.text = %q, want preserved caller billing header %q", got, billing)
|
||||
}
|
||||
if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
|
||||
t.Fatalf("system.1.text = %q, want preserved caller CLI identity", got)
|
||||
}
|
||||
if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
|
||||
t.Fatalf("metadata.user_id = %q, want preserved %q", got, userID)
|
||||
}
|
||||
if got := claudeFingerprintHeaderValue(seenHeaders, "X-App"); got != "cli" {
|
||||
t.Fatalf("X-App = %q, want preserved cli", got)
|
||||
}
|
||||
if got := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta"); !strings.Contains(got, "claude-code-20250219") {
|
||||
t.Fatalf("Anthropic-Beta = %q, want preserved claude-code-20250219", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +496,11 @@ func claudeJSONObjectHasKeys(raw []byte, want []string) bool {
|
||||
return errClosing == nil && closing == json.Delim('}') && keyIndex == len(want)
|
||||
}
|
||||
|
||||
// IsClaudeCodeUserAgent reports whether userAgent identifies a Claude Code CLI client.
|
||||
func IsClaudeCodeUserAgent(userAgent string) bool {
|
||||
return claudeCodeUserAgentPattern.MatchString(strings.TrimSpace(userAgent))
|
||||
}
|
||||
|
||||
func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool {
|
||||
userAgent = strings.TrimSpace(userAgent)
|
||||
if !claudeCodeUserAgentPattern.MatchString(userAgent) || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) {
|
||||
|
||||
Reference in New Issue
Block a user