on main help sign in
fix(copilot): guard nil request body in initiator transport (#3246)

RoundTrip only checked req.Body == http.NoBody before reading the body with io.ReadAll. A bodyless request has req.Body == nil, which is a distinct value from http.NoBody, so it fell through to io.ReadAll(nil) and panicked with a nil pointer dereference. This surfaces on a retried request (e.g. via GenerateTitle) whose body was already consumed, taking down the session from a background goroutine. Treat a nil body the same as http.NoBody and default X-Initiator to user.

commit78a205cdb5f988aa6daa6c1907e88b4b9d2b2769 authorTruffle <truffleagent@gmail.com> dateFri 3 Jul 2026, 09:47 (3 months ago) parent74ad983
2 files changed+74−2
internal/oauth/copilot/client.go+4−2 internal/oauth/copilot/client_test.go+70
internal/oauth/copilot/client.go+4−2
@@ -37,8 +37,10 @@ func (t *initiatorTransport) RoundTrip(req *http.Request) (*http.Response, error
3737 if req == nil {
3838 return nil, fmt.Errorf("HTTP request is nil")
3939 }
40− if req.Body == http.NoBody {
41− // No body to inspect; default to user.
40+ if req.Body == nil || req.Body == http.NoBody {
41+ // No body to inspect; default to user. A nil Body is valid for
42+ // bodyless requests (e.g. GET), and is distinct from http.NoBody,
43+ // so both must be handled before reading below.
4244 req.Header.Set(xInitiatorHeader, userInitiator)
4345 slog.Debug("Setting X-Initiator header to user (no request body)")
4446 return t.roundTrip(req)
internal/oauth/copilot/client_test.gonew file+70
@@ -0,0 +1,70 @@
1+package copilot
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestInitiatorTransportSetsHeader(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ // body builds the request body; nil means a bodyless request.
16+ body func() (int, string)
17+ want string
18+ }{
19+ {
20+ name: "nil body defaults to user",
21+ body: func() (int, string) { return 0, "" }, // req.Body == nil
22+ want: "user",
23+ },
24+ {
25+ name: "NoBody defaults to user",
26+ body: func() (int, string) { return -1, "" }, // req.Body == http.NoBody
27+ want: "user",
28+ },
29+ {
30+ name: "user-only history stays user",
31+ body: func() (int, string) { return 1, `{"messages":[{"role":"user"}]}` },
32+ want: "user",
33+ },
34+ {
35+ name: "assistant history becomes agent",
36+ body: func() (int, string) { return 1, `{"messages":[{"role":"assistant"}]}` },
37+ want: "agent",
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ var got string
44+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ got = r.Header.Get("X-Initiator")
46+ }))
47+ defer srv.Close()
48+
49+ kind, payload := tt.body()
50+ var req *http.Request
51+ var err error
52+ switch kind {
53+ case 0:
54+ req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
55+ case -1:
56+ req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, http.NoBody)
57+ default:
58+ req, err = http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL, strings.NewReader(payload))
59+ }
60+ require.NoError(t, err)
61+
62+ client := &http.Client{Transport: &initiatorTransport{}}
63+ resp, err := client.Do(req)
64+ require.NoError(t, err)
65+ resp.Body.Close()
66+
67+ require.Equal(t, tt.want, got)
68+ })
69+ }
70+}