diff --git a/internal/gateway/codex_client_headers_test.go b/internal/gateway/codex_client_headers_test.go new file mode 100644 index 00000000..0594ffe9 --- /dev/null +++ b/internal/gateway/codex_client_headers_test.go @@ -0,0 +1,107 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// A relay that serves only Codex (Discord: "This account only allows Codex +// official clients", sub2api's codex_cli_only: an official User-Agent or +// originator, and an x-codex- header) gets Codex's own headers as it sent +// them, the provider's key in place of magpie's, on the Responses API and +// on Chat Completions, relayed or translated +func TestCodexHeadersReachKeyedRelay(t *testing.T) { + codex := http.Header{ + "User-Agent": {"codex_exec/0.159.2 (Mac OS 26.6.0; arm64) kitty (codex_exec; 0.159.2)"}, + "Originator": {"codex_exec"}, + "Session-Id": {"ses-1"}, + "Thread-Id": {"ses-1"}, + "Session_id": {"ses-1"}, + "Version": {"0.159.2"}, + "X-Client-Request-Id": {"ses-1"}, + "X-Codex-Window-Id": {"ses-1:0"}, + "X-Codex-Beta-Features": {"remote_compaction_v2"}, + "X-Codex-Turn-Metadata": {`{"session_id":"ses-1","turn_id":"t1"}`}, + "X-Openai-Subagent": {"review"}, + "Authorization": {"Bearer " + Token}, + "X-Oai-Attestation": {"att"}, + "Chatgpt-Account-Id": {"acct"}, + } + secret := []string{"Authorization", "X-Oai-Attestation", "Chatgpt-Account-Id"} + for _, c := range []struct { + proto provider.Protocol + path, body string + ctype string + reply string + }{ + {provider.Responses, "/v1/responses", `{"model":"fake/m1","input":"hi","stream":false}`, + "application/json", `{"id":"r","object":"response","status":"completed","output":[]}`}, + // Codex's default web_search makes magpie build the request again + {provider.Responses, "/v1/responses", `{"model":"fake/m1","input":"hi","stream":false,"tools":[{"type":"web_search","external_web_access":false}]}`, + "", "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\"output\":[]}}\n\n"}, + {provider.Chat, "/v1/responses", `{"model":"fake/m1","input":"hi","stream":false}`, + "", "data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"OK\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"}, + } { + f := &fake{t: t, ctype: c.ctype, reply: c.reply} + setup(t, c.proto, f) + send := func(h http.Header) { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", c.path, strings.NewReader(c.body)) + for k, v := range h { + req.Header[k] = v + } + New().Handler().ServeHTTP(rec, req) + if rec.Code != 200 || f.calls == 0 { + t.Fatalf("%s %s: %d %s", c.proto, c.body, rec.Code, rec.Body.String()) + } + } + + send(codex) + for k, v := range codex { + if slices.Contains(secret, k) { + continue + } + if got := f.head.Get(k); got != v[0] { + t.Errorf("%s: %s: got %q, want %q", c.proto, k, got, v[0]) + } + } + if a := f.head.Get("Authorization"); a != "Bearer k" { + t.Errorf("%s: auth %q", c.proto, a) + } + for _, k := range secret[1:] { + if f.head.Get(k) != "" { + t.Errorf("%s: %s leaked", c.proto, k) + } + } + + // a provider with no key of its own still never sees magpie's + p, _ := provider.Find("fake") + p.Key = "" + provider.Save(*p) + send(codex) + if a := f.head.Get("Authorization"); a != "" { + t.Errorf("%s: magpie's key leaked: %q", c.proto, a) + } + if f.head.Get("Originator") != "codex_exec" { + t.Errorf("%s: originator: %v", c.proto, f.head) + } + + // the desktop app is told by its originator as well + send(http.Header{"User-Agent": {"Codex Desktop/0.162.3 (Windows 10.0.26100; x86_64) unknown"}, "Originator": {"Codex Desktop"}, "X-Codex-Window-Id": {"w"}}) + if f.head.Get("Originator") != "Codex Desktop" || !strings.HasPrefix(f.head.Get("User-Agent"), "Codex Desktop/") || f.head.Get("X-Codex-Window-Id") != "w" { + t.Errorf("%s: desktop: %v", c.proto, f.head) + } + + // another agent's request goes as magpie's + send(http.Header{"User-Agent": {"opencode/1.0"}, "X-Codex-Window-Id": {"w"}, "Session-Id": {"s"}}) + if ua := f.head.Get("User-Agent"); !strings.HasPrefix(ua, "magpie/") || f.head.Get("X-Codex-Window-Id") != "" || f.head.Get("Session-Id") != "" { + t.Errorf("%s: other agent: %q %v", c.proto, ua, f.head) + } + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 1047a52c..92ff8275 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1325,6 +1325,18 @@ func (s *Server) forwardOnce(ctx context.Context, p provider.Provider, to provid req.Header.Del("anthropic-beta") } } + if p.Account == nil && (to == provider.Responses || to == provider.Chat) && fromCodex(in) { + // a relay that serves only Codex (Discord: "This account only + // allows Codex official clients") knows it by its User-Agent, + // originator and x-codex- headers, which go on as Codex sent them, + // as they do when Codex talks to the relay itself; its key to + // magpie never does + for k, vs := range in { + if codexClientHeader(k) { + req.Header[k] = slices.Clone(vs) + } + } + } if p.IsOpenCode() { req.Header.Set("x-opencode-session", conversationID(in, body)) } @@ -1369,6 +1381,31 @@ func claudeCodeHeader(k string) bool { return false } +// fromCodex is a request Codex sent — its CLI, exec, the IDE extension or +// the desktop app — by the User-Agent (codex_cli_rs/0.159.2 (Mac OS 26.6.0; +// arm64) kitty, Codex Desktop/0.162.3 …) or the originator it gives +// (codex_cli_rs, codex_exec, codex_vscode, Codex Desktop). +func fromCodex(in http.Header) bool { + for _, v := range []string{in.Get("User-Agent"), in.Get("originator")} { + if strings.HasPrefix(strings.ToLower(v), "codex") { + return true + } + } + return false +} + +// codexClientHeader is one of the headers Codex tells itself by to an API +// it talks to with a key: its User-Agent and originator, the session and +// thread (session-id, thread-id), and what codexHeader lets through. Never +// the key it was given, nor its sign-in's attestation. +func codexClientHeader(k string) bool { + switch strings.ToLower(k) { + case "user-agent", "originator", "session-id", "thread-id": + return true + } + return codexHeader(k) +} + // passthrough relays a request the provider understands as-is, with the // model name swapped for the provider's own. The token counts the reply // carries are read on the way past into u. done is false, with nothing