git-remote-entire: stamp HTTP User-Agent on outbound requests · Entire

git-remote-entire: stamp HTTP User-Agent on outbound requests

The helper already advertises itself via the git protocol pkt-line agent= capability (commit-stamped), but every HTTP request it makes — info/refs, upload-pack/receive-pack POSTs, well-known discovery, STS exchanges, refresh-token rotations — went out with Go's default "Go-http-client/1.1", making the helper invisible in upstream access logs and indistinguishable from any other Go client hitting the data plane.

Add httpclient.UserAgentTransport, a small RoundTripper wrapper that stamps User-Agent on each outbound request, and wire it into both the auth-side *http.Client and the transport.Proxy so every credentialled hop from the helper carries "git-remote-entire/". The wrapper sits outside the httpdebug RoundTripper so the debug log captures the same headers the wire sees. UserAgent on transport.Config is optional — empty falls back to Go's default, which keeps the existing tests that don't care about identity working unchanged.

Format is version-stamped (binary/version) to match the conventional form the rest of the entire HTTP clients use; the git protocol agent stays commit-stamped where it is, since the two serve different correlation needs and changing it risks breaking server-side log analysis.

Assisted-by: Claude Opus 4.7 noreply@anthropic.com Signed-off-by: Paulo Gomes paulo@entire.io

Sessions

Changes

5

package httpclient

import "net/http"

// UserAgentTransport wraps another http.RoundTripper and stamps the
// User-Agent header on every outgoing request so the server can
// attribute traffic to the calling binary. Callers that already set
// User-Agent are overwritten: the wrapper exists to give the binary a
// single identity in upstream access logs, not to be overridden per
// request.

// The wrapper clones the request before mutating headers so the
// caller's original *http.Request is left untouched — important for
// retries and for callers that hold a reference after Do returns.

// Concurrent use is safe iff Next is safe for concurrent use.
type UserAgentTransport struct {
    Next http.RoundTripper
    UA   string
}

// RoundTrip implements http.RoundTripper.
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    r := req.Clone(req.Context())
    r.Header.Set("User-Agent", t.UA)
    return t.Next.RoundTrip(r)
}
func TestUserAgentTransport_SetsHeader(t *testing.T) {
    t.Parallel()

var got string
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        got = r.Header.Get("User-Agent")
        w.WriteHeader(http.StatusOK)
    }))
    t.Cleanup(srv.Close)

client := &http.Client{
        Transport: &UserAgentTransport{
            Next: http.DefaultTransport,
            UA:   "test-binary/1.2.3",
        },
    }
    req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
    if err != nil {
        t.Fatalf("NewRequest: %v", err)
    }
    resp, err := client.Do(req)
    if err != nil {
        t.Fatalf("Do: %v", err)
    }
    _ = resp.Body.Close()

if want := "test-binary/1.2.3"; got != want {
        t.Errorf("User-Agent = %q, want %q", got, want)
    }
}
func TestUserAgentTransport_OverwritesCallerHeader(t *testing.T) {
    t.Parallel()

client := &http.Client{
        Transport: &UserAgentTransport{
            Next: http.DefaultTransport,
            UA:   "wrapper-set",
        },
    }
    req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
    if err != nil {
        t.Fatalf("NewRequest: %v", err)
    }
    req.Header.Set("User-Agent", "caller-set")
    resp, err := client.Do(req)
    if err != nil {
        t.Fatalf("Do: %v", err)
    }
    _ = resp.Body.Close()

if want := "wrapper-set"; got != want {
        t.Errorf("User-Agent = %q, want %q", got, want)
    }
}
func TestUserAgentTransport_DoesNotMutateCallerRequest(t *testing.T) {
    t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))
    t.Cleanup(srv.Close)

if got := req.Header.Get("User-Agent"); got != "caller-set" {
        t.Errorf("caller request mutated: User-Agent = %q, want %q", got, "caller-set")
    }
}
func TestProxy_SetsUserAgentHeader(t *testing.T) {
    t.Parallel()

var got string
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        got = r.Header.Get("User-Agent")
        w.WriteHeader(http.StatusOK)
    }))
    t.Cleanup(server.Close)

p := New(Config{
        Nodes: replicas.NodeConfig{
            InitialNodes: []string{server.URL},
            EntryURL:     server.URL,
            ClusterHost:  mustHost(t, server.URL),
            RepoPath:     "owner/repo",
        },
        Path:      "/et/owner/repo",
        UserAgent: "git-remote-entire/9.9.9",
    })

resp, err := p.InfoRefs(t.Context(), serviceParam)
    if err != nil {
        t.Fatalf("InfoRefs: %v", err)
    }
    _ = resp.Close()

if want := "git-remote-entire/9.9.9"; got != want {
        t.Errorf("User-Agent = %q, want %q", got, want)
    }
}
func TestProxy_OmitsUserAgentWrapperWhenEmpty(t *testing.T) {
    t.Parallel()

p := New(Config{
        Nodes: replicas.NodeConfig{
            InitialNodes: []string{server.URL},
            EntryURL:     server.URL,
            ClusterHost:  mustHost(t, server.URL),
            RepoPath:     "owner/repo",
        },
        Path: "/et/owner/repo",
    })

resp, err := p.InfoRefs(t.Context(), serviceParam)
    if err != nil {
        t.Fatalf("InfoRefs: %v", err)
    }
    _ = resp.Close()

if !strings.HasPrefix(got, "Go-http-client/") {
        t.Errorf("User-Agent = %q, want Go's default when wrapper omitted", got)
    }
}