Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/config/init_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)

// configInitResult holds the result of the interactive config init flow.
Expand Down Expand Up @@ -179,7 +179,7 @@
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := util.NewHTTPClient(0)
httpClient := transport.NewHTTPClient(0)

Check warning on line 182 in cmd/config/init_interactive.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_interactive.go#L182

Added line #L182 was not covered by tests
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
Expand Down
4 changes: 2 additions & 2 deletions cmd/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/util"
)

// DoctorOptions holds inputs for the doctor command.
Expand Down Expand Up @@ -155,7 +155,7 @@

// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := util.NewHTTPClient(0)
httpClient := transport.NewHTTPClient(0)

Check warning on line 158 in cmd/doctor/doctor.go

View check run for this annotation

Codecov / codecov/patch

cmd/doctor/doctor.go#L158

Added line #L158 was not covered by tests
mcpURL := ep.MCP + "/mcp"

type probeResult struct {
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)

// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
Expand All @@ -28,7 +28,7 @@
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()

Check warning on line 31 in internal/auth/transport.go

View check run for this annotation

Codecov / codecov/patch

internal/auth/transport.go#L31

Added line #L31 was not covered by tests
}

// RoundTrip implements http.RoundTripper.
Expand Down
20 changes: 10 additions & 10 deletions internal/cmdutil/factory_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)

Expand Down Expand Up @@ -102,15 +102,15 @@

func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
util.WarnIfProxied(f.IOStreams.ErrOut)
transport.WarnIfProxied(f.IOStreams.ErrOut)
Comment thread
liangshuo-1 marked this conversation as resolved.

var transport http.RoundTripper = util.SharedTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
transport = wrapWithExtension(transport)
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
client := &http.Client{
Transport: transport,
Transport: rt,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
Expand All @@ -129,7 +129,7 @@
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
util.WarnIfProxied(f.IOStreams.ErrOut)
transport.WarnIfProxied(f.IOStreams.ErrOut)

Check warning on line 132 in internal/cmdutil/factory_default.go

View check run for this annotation

Codecov / codecov/patch

internal/cmdutil/factory_default.go#L132

Added line #L132 was not covered by tests
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
Expand All @@ -141,7 +141,7 @@
}

func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.SharedTransport()
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
Expand Down
10 changes: 5 additions & 5 deletions internal/cmdutil/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"time"

exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
)

// RetryTransport is an http.RoundTripper that retries on 5xx responses
Expand All @@ -24,7 +24,7 @@
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()

Check warning on line 27 in internal/cmdutil/transport.go

View check run for this annotation

Codecov / codecov/patch

internal/cmdutil/transport.go#L27

Added line #L27 was not covered by tests
}

func (t *RetryTransport) delay() time.Duration {
Expand Down Expand Up @@ -69,7 +69,7 @@
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
return transport.Fallback().RoundTrip(req)

Check warning on line 72 in internal/cmdutil/transport.go

View check run for this annotation

Codecov / codecov/patch

internal/cmdutil/transport.go#L72

Added line #L72 was not covered by tests
}

// BuildHeaderTransport is an http.RoundTripper that force-writes the
Expand All @@ -87,7 +87,7 @@
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return util.FallbackTransport().RoundTrip(req)
return transport.Fallback().RoundTrip(req)
}

// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
Expand All @@ -100,7 +100,7 @@
if t.Base != nil {
return t.Base
}
return util.FallbackTransport()
return transport.Fallback()

Check warning on line 103 in internal/cmdutil/transport.go

View check run for this annotation

Codecov / codecov/patch

internal/cmdutil/transport.go#L103

Added line #L103 was not covered by tests
}

// RoundTrip implements http.RoundTripper.
Expand Down
2 changes: 1 addition & 1 deletion internal/cmdutil/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {

// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
// the transport still sets X-Cli-Build and routes the request through
// util.FallbackTransport rather than panicking. This covers the fallback
// transport.Fallback rather than panicking. This covers the fallback
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
var receivedBuild string
Expand Down
4 changes: 2 additions & 2 deletions internal/registry/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
Expand Down Expand Up @@ -181,7 +181,7 @@ func saveCachedMerged(data []byte, meta CacheMeta) error {
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
client := util.NewHTTPClient(fetchTimeout)
client := transport.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {
return nil, nil, err
Expand Down
30 changes: 8 additions & 22 deletions internal/proxyplugin/config.go → internal/transport/config.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode.
// Package transport owns how the CLI assembles its outbound HTTP transport: the
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
//
// It supports:
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
//
// Environment variables override matching values from proxy_config.json.
package proxyplugin
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection
// proxies, and fails closed on misconfiguration. Environment variables override
// matching values from proxy_config.json.
package transport

import (
"encoding/json"
Expand Down Expand Up @@ -222,21 +223,6 @@ func (c *Config) proxyURL() (*url.URL, error) {
return u, nil
}

// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
func redactProxyURL(raw string) string {
u, err := url.Parse(raw)
if err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}

// ApplyToTransport clones base and applies proxy plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package proxyplugin
package transport

import (
"net/http"
Expand Down
83 changes: 83 additions & 0 deletions internal/transport/shared.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package transport

import (
"net/http"
"os"
"sync"
"time"
)

// Shared returns the base http.RoundTripper for all CLI HTTP clients.
//
// Precedence (highest first):
// 1. proxy-plugin mode — force traffic through a fixed loopback proxy;
// FAIL-CLOSED when the plugin config exists but is invalid.
// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled.
// 3. http.DefaultTransport — the stdlib process-wide singleton (honors
// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache.
//
// The returned RoundTripper MUST NOT be mutated. Callers that need a customized
// transport should assert to *http.Transport and Clone() it. A shared base is
// required so persistConn read/write goroutines are reused; cloning per call
// leaks them until IdleConnTimeout (~90s) fires.
func Shared() http.RoundTripper {
// Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When
// the plugin config exists but is invalid, pluginTransport returns a
// fail-closed transport with ok=true and we return it here — we MUST NOT
// fall through to the NO_PROXY / DefaultTransport direct-egress paths below.
if t, ok := pluginTransport(); ok {
return t
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}
return http.DefaultTransport
}

// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared
// retained so modules already on the leak-free singleton path (internal/auth,
// internal/cmdutil transport decorators) do not have to migrate. New code
// should prefer Shared and treat the base as an http.RoundTripper.
//
// Fail-closed invariant: pluginTransport always expresses its blocked transport
// as a concrete *http.Transport (see failClosedTransport), so the assertion
// below preserves the block. The noProxyTransport() fallback is therefore only
// reached when no proxy plugin is configured and some external code replaced
// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed
// intent, where a proxy-disabled transport is acceptable.
func Fallback() *http.Transport {
if t, ok := Shared().(*http.Transport); ok {
return t

Check warning on line 53 in internal/transport/shared.go

View check run for this annotation

Codecov / codecov/patch

internal/transport/shared.go#L51-L53

Added lines #L51 - L53 were not covered by tests
}
return noProxyTransport()

Check warning on line 55 in internal/transport/shared.go

View check run for this annotation

Codecov / codecov/patch

internal/transport/shared.go#L55

Added line #L55 was not covered by tests
}

// NewHTTPClient returns an *http.Client whose Transport is the shared,
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
// for outbound requests: a bare client falls back to http.DefaultTransport and
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
// fail-closed), creating an audit blind spot.
//
// A zero timeout means no client-level timeout (callers relying on context
// deadlines pass 0).
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: Shared(),
Timeout: timeout,
}
}

// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
// built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}

Check warning on line 78 in internal/transport/shared.go

View check run for this annotation

Codecov / codecov/patch

internal/transport/shared.go#L78

Added line #L78 was not covered by tests
}
t := def.Clone()
t.Proxy = nil
return t
})
Loading
Loading