diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index ecab3c874c..de51d12ec9 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -106,8 +106,11 @@ func NewAntigravityExecutor(cfg *config.Config) *AntigravityExecutor { // It is initialized once via antigravityTransportOnce to avoid leaking a new connection pool // (and the goroutines managing it) on every request. var ( - antigravityTransport *http.Transport - antigravityTransportOnce sync.Once + antigravityTransport *http.Transport + antigravityTransportOnce sync.Once + antigravityEnvironmentProxyTransport = sync.OnceValue(func() *http.Transport { + return cloneTransportWithHTTP11(helps.EnvironmentProxyTransport()) + }) ) func cloneTransportWithHTTP11(base *http.Transport) *http.Transport { @@ -153,6 +156,10 @@ func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cli // Preserve proxy settings from proxy-aware transports while forcing HTTP/1.1. if transport, ok := client.Transport.(*http.Transport); ok { + if transport == helps.EnvironmentProxyTransport() { + client.Transport = antigravityEnvironmentProxyTransport() + return client + } client.Transport = cloneTransportWithHTTP11(transport) } return client diff --git a/internal/runtime/executor/antigravity_executor_client_test.go b/internal/runtime/executor/antigravity_executor_client_test.go new file mode 100644 index 0000000000..97ee0aeba7 --- /dev/null +++ b/internal/runtime/executor/antigravity_executor_client_test.go @@ -0,0 +1,59 @@ +package executor + +import ( + "context" + "net/http" + "os" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func setAntigravityEnvironmentProxy(t *testing.T, proxyURL string) { + t.Helper() + + for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY"} { + oldValue, hadValue := os.LookupEnv(key) + if err := os.Setenv(key, proxyURL); err != nil { + t.Fatalf("Setenv(%s): %v", key, err) + } + cleanupKey := key + cleanupOldValue := oldValue + cleanupHadValue := hadValue + t.Cleanup(func() { + if cleanupHadValue { + _ = os.Setenv(cleanupKey, cleanupOldValue) + return + } + _ = os.Unsetenv(cleanupKey) + }) + } +} + +func TestNewAntigravityHTTPClientReusesSharedEnvironmentProxyTransport(t *testing.T) { + setAntigravityEnvironmentProxy(t, "http://env-proxy.example.com:8080") + + clientA := newAntigravityHTTPClient(context.Background(), &config.Config{}, &cliproxyauth.Auth{}, 0) + clientB := newAntigravityHTTPClient(context.Background(), &config.Config{}, &cliproxyauth.Auth{}, 0) + + transportA, okA := clientA.Transport.(*http.Transport) + if !okA { + t.Fatalf("clientA transport type = %T, want *http.Transport", clientA.Transport) + } + transportB, okB := clientB.Transport.(*http.Transport) + if !okB { + t.Fatalf("clientB transport type = %T, want *http.Transport", clientB.Transport) + } + + if transportA != transportB { + t.Fatal("expected Antigravity environment proxy transport to be shared across clients") + } + if transportA == helps.EnvironmentProxyTransport() { + t.Fatal("expected Antigravity transport to use its HTTP/1.1 clone, not the generic environment proxy transport") + } + if transportA.ForceAttemptHTTP2 { + t.Fatal("expected Antigravity transport to keep HTTP/2 disabled") + } +} diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go index 022bc65c17..3811ee15db 100644 --- a/internal/runtime/executor/helps/proxy_helpers.go +++ b/internal/runtime/executor/helps/proxy_helpers.go @@ -3,7 +3,9 @@ package helps import ( "context" "net/http" + "os" "strings" + "sync" "time" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -12,10 +14,22 @@ import ( log "github.com/sirupsen/logrus" ) +var environmentProxyKeys = []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} + +var sharedEnvironmentProxyTransport = sync.OnceValue(func() *http.Transport { + if transport, ok := http.DefaultTransport.(*http.Transport); ok && transport != nil { + clone := transport.Clone() + clone.Proxy = http.ProxyFromEnvironment + return clone + } + return &http.Transport{Proxy: http.ProxyFromEnvironment} +}) + // NewProxyAwareHTTPClient creates an HTTP client with proper proxy configuration priority: // 1. Use auth.ProxyURL if configured (highest priority) // 2. Use cfg.ProxyURL if auth proxy is not configured -// 3. Use RoundTripper from context if neither are configured +// 3. Use environment proxy settings if neither are configured +// 4. Use RoundTripper from context if no explicit or environment proxy is configured // // Parameters: // - ctx: The context containing optional RoundTripper @@ -53,7 +67,14 @@ func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip log.Debugf("failed to setup proxy from URL: %s, falling back to context transport", proxyURL) } - // Priority 3: Use RoundTripper from context (typically from RoundTripperFor) + // Priority 3: Use environment proxy settings explicitly so all callers share + // the same fallback semantics instead of relying on http.DefaultTransport. + if environmentProxyConfigured() { + httpClient.Transport = newEnvironmentProxyTransport() + return httpClient + } + + // Priority 4: Use RoundTripper from context (typically from RoundTripperFor) if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil { httpClient.Transport = rt } @@ -77,3 +98,21 @@ func buildProxyTransport(proxyURL string) *http.Transport { } return transport } + +func environmentProxyConfigured() bool { + for _, key := range environmentProxyKeys { + if strings.TrimSpace(os.Getenv(key)) != "" { + return true + } + } + return false +} + +func newEnvironmentProxyTransport() *http.Transport { + return sharedEnvironmentProxyTransport() +} + +// EnvironmentProxyTransport returns the shared transport used for environment proxy fallback. +func EnvironmentProxyTransport() *http.Transport { + return newEnvironmentProxyTransport() +} diff --git a/internal/runtime/executor/helps/proxy_helpers_test.go b/internal/runtime/executor/helps/proxy_helpers_test.go index 3311716765..bbb9416382 100644 --- a/internal/runtime/executor/helps/proxy_helpers_test.go +++ b/internal/runtime/executor/helps/proxy_helpers_test.go @@ -3,6 +3,7 @@ package helps import ( "context" "net/http" + "os" "testing" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -10,6 +11,27 @@ import ( sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" ) +func setEnvironmentProxy(t *testing.T, proxyURL string) { + t.Helper() + + for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY"} { + oldValue, hadValue := os.LookupEnv(key) + if err := os.Setenv(key, proxyURL); err != nil { + t.Fatalf("Setenv(%s): %v", key, err) + } + cleanupKey := key + cleanupOldValue := oldValue + cleanupHadValue := hadValue + t.Cleanup(func() { + if cleanupHadValue { + _ = os.Setenv(cleanupKey, cleanupOldValue) + return + } + _ = os.Unsetenv(cleanupKey) + }) + } +} + func TestNewProxyAwareHTTPClientDirectBypassesGlobalProxy(t *testing.T) { t.Parallel() @@ -28,3 +50,74 @@ func TestNewProxyAwareHTTPClientDirectBypassesGlobalProxy(t *testing.T) { t.Fatal("expected direct transport to disable proxy function") } } + +func TestNewProxyAwareHTTPClientFallsBackToEnvironmentProxy(t *testing.T) { + setEnvironmentProxy(t, "http://env-proxy.example.com:8080") + + client := NewProxyAwareHTTPClient(context.Background(), &config.Config{}, &cliproxyauth.Auth{}, 0) + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T, want *http.Transport", client.Transport) + } + if transport.Proxy == nil { + t.Fatal("expected environment proxy transport to configure Proxy function") + } + req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + proxyURL, errProxy := transport.Proxy(req) + if errProxy != nil { + t.Fatalf("transport.Proxy() error = %v", errProxy) + } + if proxyURL == nil || proxyURL.String() != "http://env-proxy.example.com:8080" { + t.Fatalf("proxy URL = %v, want http://env-proxy.example.com:8080", proxyURL) + } +} + +func TestNewProxyAwareHTTPClientExplicitProxyWinsOverEnvironmentProxy(t *testing.T) { + setEnvironmentProxy(t, "http://env-proxy.example.com:8080") + + client := NewProxyAwareHTTPClient( + context.Background(), + &config.Config{SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://config-proxy.example.com:8080"}}, + nil, + 0, + ) + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("transport type = %T, want *http.Transport", client.Transport) + } + req, errReq := http.NewRequest(http.MethodGet, "https://example.com", nil) + if errReq != nil { + t.Fatalf("NewRequest() error = %v", errReq) + } + proxyURL, errProxy := transport.Proxy(req) + if errProxy != nil { + t.Fatalf("transport.Proxy() error = %v", errProxy) + } + if proxyURL == nil || proxyURL.String() != "http://config-proxy.example.com:8080" { + t.Fatalf("proxy URL = %v, want http://config-proxy.example.com:8080", proxyURL) + } +} + +func TestNewProxyAwareHTTPClientReusesEnvironmentProxyTransport(t *testing.T) { + setEnvironmentProxy(t, "http://env-proxy.example.com:8080") + + clientA := NewProxyAwareHTTPClient(context.Background(), &config.Config{}, &cliproxyauth.Auth{}, 0) + clientB := NewProxyAwareHTTPClient(context.Background(), &config.Config{}, &cliproxyauth.Auth{}, 0) + + transportA, okA := clientA.Transport.(*http.Transport) + if !okA { + t.Fatalf("clientA transport type = %T, want *http.Transport", clientA.Transport) + } + transportB, okB := clientB.Transport.(*http.Transport) + if !okB { + t.Fatalf("clientB transport type = %T, want *http.Transport", clientB.Transport) + } + if transportA != transportB { + t.Fatal("expected environment proxy transport to be shared across clients") + } +}