gateway: Codex's own headers go on to a keyed provider, as Claude Code's do (#179) (lx_78654 on Discord: "This account only allows Codex official clients") — magpie sent Codex's requests as magpie/<version> with none of Codex's headers, so a provider that serves only Codex's clients (by User-Agent or originator, a version and an x-codex- header) refused them. A request from Codex (User-Agent or originator starting codex: codex_cli_rs, codex_exec, codex_vscode, Codex Desktop) to a key+URL provider over Responses or Chat, relayed or rebuilt, now carries its User-Agent, originator, session-id, thread-id and the x-codex- headers codexHeader passes; the provider's own key is the one sent, never magpie's, nor Codex's attestation; other agents go as magpie/<version>, and a provider's own User-Agent is still set when it signs. Headers as codex-cli 0.159.2 sent them to a fake upstream in a sandbox HOME. TestCodexHeadersReachKeyedRelay fails without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.
This commit is contained in:
2 files changed
+144
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user