From 79a7ba8864576f4ea6fe144901dfb2a149be2fc1 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 17:37:27 +0800 Subject: [PATCH] =?UTF-8?q?gateway:=20Codex's=20own=20headers=20go=20on=20?= =?UTF-8?q?to=20a=20keyed=20provider,=20as=20Claude=20Code's=20do=20(#179)?= =?UTF-8?q?=20(lx=5F78654=20on=20Discord:=20"This=20account=20only=20allow?= =?UTF-8?q?s=20Codex=20official=20clients")=20=E2=80=94=20magpie=20sent=20?= =?UTF-8?q?Codex's=20requests=20as=20magpie/=20with=20none=20of?= =?UTF-8?q?=20Codex's=20headers,=20so=20a=20provider=20that=20serves=20onl?= =?UTF-8?q?y=20Codex's=20clients=20(by=20User-Agent=20or=20originator,=20a?= =?UTF-8?q?=20version=20and=20an=20x-codex-=20header)=20refused=20them.=20?= =?UTF-8?q?A=20request=20from=20Codex=20(User-Agent=20or=20originator=20st?= =?UTF-8?q?arting=20codex:=20codex=5Fcli=5Frs,=20codex=5Fexec,=20codex=5Fv?= =?UTF-8?q?scode,=20Codex=20Desktop)=20to=20a=20key+URL=20provider=20over?= =?UTF-8?q?=20Responses=20or=20Chat,=20relayed=20or=20rebuilt,=20now=20car?= =?UTF-8?q?ries=20its=20User-Agent,=20originator,=20session-id,=20thread-i?= =?UTF-8?q?d=20and=20the=20x-codex-=20headers=20codexHeader=20passes;=20th?= =?UTF-8?q?e=20provider's=20own=20key=20is=20the=20one=20sent,=20never=20m?= =?UTF-8?q?agpie's,=20nor=20Codex's=20attestation;=20other=20agents=20go?= =?UTF-8?q?=20as=20magpie/,=20and=20a=20provider's=20own=20User-A?= =?UTF-8?q?gent=20is=20still=20set=20when=20it=20signs.=20Headers=20as=20c?= =?UTF-8?q?odex-cli=200.159.2=20sent=20them=20to=20a=20fake=20upstream=20i?= =?UTF-8?q?n=20a=20sandbox=20HOME.=20TestCodexHeadersReachKeyedRelay=20fai?= =?UTF-8?q?ls=20without=20the=20change.=20go=20vet,=20go=20build=20-tags?= =?UTF-8?q?=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows=20go=20bu?= =?UTF-8?q?ild,=20go=20test=20-tags=20nogui=20./...=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/codex_client_headers_test.go | 107 ++++++++++++++++++ internal/gateway/gateway.go | 37 ++++++ 2 files changed, 144 insertions(+) create mode 100644 internal/gateway/codex_client_headers_test.go 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