From 85d2faddd17e6f4f8675a84ee28b131f702e8eaa Mon Sep 17 00:00:00 2001 From: sususu98 <33882693+sususu98@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:30:41 +0800 Subject: [PATCH] fix(claude): preserve native subagent and environment headers (#4982) (#5084) --- .../executor/claude_executor_request.go | 18 +++- .../runtime/executor/claude_executor_test.go | 97 +++++++++++++++++++ .../executor/helps/claude_code_session.go | 5 + 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 486cf7e39..d72b5e2a5 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -624,7 +624,10 @@ func copyClaudeCallerFingerprintHeaders(dst, src http.Header) { lowerName != "x-app" && lowerName != "x-client-request-id" && !strings.HasPrefix(lowerName, "anthropic-") && !strings.HasPrefix(lowerName, "x-stainless-") && - !strings.HasPrefix(lowerName, "x-claude-code-") { + !strings.HasPrefix(lowerName, "x-claude-code-") && + !strings.HasPrefix(lowerName, "x-claude-remote-") && + lowerName != "x-client-app" && + lowerName != "x-anthropic-additional-protection" { continue } dst.Del(name) @@ -880,6 +883,19 @@ func applyClaudeHeadersWithNativeProfile( } identityHeader("X-Claude-Code-Session-Id", sessionID) } + // Preserve native Claude Code subagent and environment headers when present in the incoming request. + for _, hdr := range []string{ + "X-Claude-Code-Agent-Id", + "X-Claude-Code-Parent-Agent-Id", + "X-Claude-Remote-Container-Id", + "X-Claude-Remote-Session-Id", + "X-Client-App", + "X-Anthropic-Additional-Protection", + } { + if val := helps.HeaderValueCaseInsensitive(incomingHeaders, hdr); val != "" { + r.Header.Set(hdr, val) + } + } // Per-request UUID, matches Claude Code's x-client-request-id for first-party API. // identityHeader prefers the incoming value for a confirmed client, so a confirmed // helper keeps its own native request ID and this fresh UUID only covers a caller diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 36a409edd..b83c0d469 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -6319,3 +6319,100 @@ func TestClaudeExecutor_CacheTTLIsPairedWithExtendedCacheTTLBeta(t *testing.T) { }) } } + +func TestClaudeExecutor_PreservesNativeAgentAndEnvironmentHeaders(t *testing.T) { + tests := []struct { + name string + incomingHeaders http.Header + wantHeaders map[string]string + wantAbsent []string + }{ + { + name: "preserves canonical agent and parent agent headers", + incomingHeaders: http.Header{ + "X-Claude-Code-Agent-Id": {"subagent-001"}, + "X-Claude-Code-Parent-Agent-Id": {"parent-agent-root"}, + }, + wantHeaders: map[string]string{ + "X-Claude-Code-Agent-Id": "subagent-001", + "X-Claude-Code-Parent-Agent-Id": "parent-agent-root", + }, + }, + { + name: "preserves lowercased agent and environment headers", + incomingHeaders: http.Header{ + "x-claude-code-agent-id": {"agent-xyz"}, + "x-claude-remote-container-id": {"container-123"}, + "x-claude-remote-session-id": {"remote-sess-456"}, + "x-client-app": {"custom-sdk"}, + "x-anthropic-additional-protection": {"true"}, + }, + wantHeaders: map[string]string{ + "X-Claude-Code-Agent-Id": "agent-xyz", + "X-Claude-Remote-Container-Id": "container-123", + "X-Claude-Remote-Session-Id": "remote-sess-456", + "X-Client-App": "custom-sdk", + "X-Anthropic-Additional-Protection": "true", + }, + }, + { + name: "does not fabricate agent header when absent", + incomingHeaders: http.Header{ + "User-Agent": {"test-client"}, + }, + wantAbsent: []string{ + "X-Claude-Code-Agent-Id", + "X-Claude-Code-Parent-Agent-Id", + "X-Claude-Remote-Container-Id", + "X-Claude-Remote-Session-Id", + "X-Client-App", + "X-Anthropic-Additional-Protection", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_agent","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + ID: "agent-header-test", + Attributes: map[string]string{ + "api_key": "sk-ant-test-key", + "base_url": server.URL, + "cloak_mode": "always", + }, + Metadata: claudeOAuthTestMetadata(), + } + + _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-opus-4-6", + Payload: []byte(`{"model":"claude-opus-4-6","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: tt.incomingHeaders, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + for wantKey, wantVal := range tt.wantHeaders { + if got := seenHeaders.Get(wantKey); got != wantVal { + t.Errorf("header %s = %q, want %q", wantKey, got, wantVal) + } + } + for _, absentKey := range tt.wantAbsent { + if got := seenHeaders.Get(absentKey); got != "" { + t.Errorf("header %s = %q, want absent", absentKey, got) + } + } + }) + } +} diff --git a/internal/runtime/executor/helps/claude_code_session.go b/internal/runtime/executor/helps/claude_code_session.go index d63584692..ea7039044 100644 --- a/internal/runtime/executor/helps/claude_code_session.go +++ b/internal/runtime/executor/helps/claude_code_session.go @@ -56,6 +56,11 @@ func claudeCodeHeader(ctx context.Context, headers http.Header, name string) str return "" } +// HeaderValueCaseInsensitive returns the first non-empty header value matching name case-insensitively. +func HeaderValueCaseInsensitive(headers http.Header, name string) string { + return headerValueCaseInsensitive(headers, name) +} + func headerValueCaseInsensitive(headers http.Header, name string) string { if headers == nil { return ""