package executor import ( "context" "crypto/tls" "encoding/base64" "encoding/json" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gorilla/websocket" codexauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" fileauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" ) func makeTestCodexRefreshJWT(planType, accountID string) string { header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) authInfo := map[string]any{ "chatgpt_account_id": accountID, } if planType != "" { authInfo["chatgpt_plan_type"] = planType } claimsMap := map[string]any{ "email": "user@example.com", "https://api.openai.com/auth": authInfo, } payloadBytes, _ := json.Marshal(claimsMap) claims := base64.RawURLEncoding.EncodeToString(payloadBytes) return header + "." + claims + "." } func startCodexMockOAuthServers(t *testing.T, idToken string) (proxyURL string, teardown func()) { t.Helper() mockAuthServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respBody := map[string]any{ "access_token": "new-mock-access-token", "refresh_token": "new-mock-refresh-token", "id_token": idToken, "token_type": "Bearer", "expires_in": 3600, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(respBody) })) mockProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodConnect { destConn, errDial := net.Dial("tcp", mockAuthServer.Listener.Addr().String()) if errDial != nil { http.Error(w, errDial.Error(), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) hijacker, ok := w.(http.Hijacker) if !ok { _ = destConn.Close() http.Error(w, "Hijacking not supported", http.StatusInternalServerError) return } clientConn, _, errHijack := hijacker.Hijack() if errHijack != nil { _ = destConn.Close() return } go func() { defer func() { _ = destConn.Close() }() defer func() { _ = clientConn.Close() }() _, _ = io.Copy(destConn, clientConn) }() go func() { defer func() { _ = destConn.Close() }() defer func() { _ = clientConn.Close() }() _, _ = io.Copy(clientConn, destConn) }() return } http.Error(w, "proxy only supports CONNECT", http.StatusBadRequest) })) // Allow self-signed cert for the mock TLS server in HTTP transport origTransport := http.DefaultTransport http.DefaultTransport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } teardown = func() { http.DefaultTransport = origTransport mockProxy.Close() mockAuthServer.Close() } return mockProxy.URL, teardown } func TestCodexExecutorRefresh_MissingPlanTypeDefaultsToFree(t *testing.T) { idTokenWithoutPlan := makeTestCodexRefreshJWT("", "acc-codex-test") proxyURL, teardown := startCodexMockOAuthServers(t, idTokenWithoutPlan) defer teardown() cfg := &config.Config{} executor := NewCodexExecutor(cfg) storage := &codexauth.CodexTokenStorage{ IDToken: "old-id-token", AccessToken: "old-access-token", RefreshToken: "old-refresh-token", PlanType: "unknown", } auth := &cliproxyauth.Auth{ ID: "test-codex-refresh", Provider: "codex", Storage: storage, ProxyURL: proxyURL, Metadata: map[string]any{ "refresh_token": "valid-refresh-token", }, Attributes: map[string]string{ "plan_type": "unknown", }, } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() refreshed, errRefresh := executor.Refresh(ctx, auth) if errRefresh != nil { t.Fatalf("executor.Refresh error = %v", errRefresh) } if got := refreshed.Attributes["plan_type"]; got != "free" { t.Errorf("refreshed.Attributes[plan_type] = %q, want free", got) } if got := refreshed.Metadata["plan_type"]; got != "free" { t.Errorf("refreshed.Metadata[plan_type] = %q, want free", got) } // Verify snapshot isolation: original storage remains unmodified if storage.PlanType != "unknown" { t.Errorf("original storage.PlanType mutated to %q, want unknown", storage.PlanType) } newStorage, ok := refreshed.Storage.(*codexauth.CodexTokenStorage) if !ok || newStorage == nil { t.Fatalf("refreshed.Storage is not *codexauth.CodexTokenStorage: %T", refreshed.Storage) } if newStorage.PlanType != "free" { t.Errorf("newStorage.PlanType = %q, want free", newStorage.PlanType) } // Verify persistence to credential file writes plan_type: free tempDir := t.TempDir() store := fileauth.NewFileTokenStore() store.SetBaseDir(tempDir) refreshed.Attributes[cliproxyauth.AttributePath] = filepath.Join(tempDir, "codex-refreshed.json") savedPath, errSave := store.Save(ctx, refreshed) if errSave != nil { t.Fatalf("store.Save error: %v", errSave) } savedBytes, errRead := os.ReadFile(savedPath) if errRead != nil { t.Fatalf("os.ReadFile error: %v", errRead) } var fileJSON map[string]any if errUnmarshal := json.Unmarshal(savedBytes, &fileJSON); errUnmarshal != nil { t.Fatalf("json.Unmarshal error: %v", errUnmarshal) } if got := fileJSON["plan_type"]; got != "free" { t.Errorf("credential file plan_type = %v, want free", got) } } func TestCodexExecutorRefresh_ExtractsPlanTypeWhenPresent(t *testing.T) { idTokenWithPlan := makeTestCodexRefreshJWT("team", "acc-codex-test-team") proxyURL, teardown := startCodexMockOAuthServers(t, idTokenWithPlan) defer teardown() cfg := &config.Config{} executor := NewCodexExecutor(cfg) storage := &codexauth.CodexTokenStorage{ IDToken: "old-id-token", AccessToken: "old-access-token", RefreshToken: "old-refresh-token", PlanType: "free", } auth := &cliproxyauth.Auth{ ID: "test-codex-refresh-team", Provider: "codex", Storage: storage, ProxyURL: proxyURL, Metadata: map[string]any{ "refresh_token": "valid-refresh-token-team", }, Attributes: map[string]string{ "plan_type": "free", }, } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() refreshed, errRefresh := executor.Refresh(ctx, auth) if errRefresh != nil { t.Fatalf("executor.Refresh error = %v", errRefresh) } if got := refreshed.Attributes["plan_type"]; got != "team" { t.Errorf("refreshed.Attributes[plan_type] = %q, want team", got) } if got := refreshed.Metadata["plan_type"]; got != "team" { t.Errorf("refreshed.Metadata[plan_type] = %q, want team", got) } // Verify snapshot isolation: original storage remains unmodified if storage.PlanType != "free" { t.Errorf("original storage.PlanType mutated to %q, want free", storage.PlanType) } newStorage, ok := refreshed.Storage.(*codexauth.CodexTokenStorage) if !ok || newStorage == nil { t.Fatalf("refreshed.Storage is not *codexauth.CodexTokenStorage: %T", refreshed.Storage) } if newStorage.PlanType != "team" { t.Errorf("newStorage.PlanType = %q, want team", newStorage.PlanType) } // Verify persistence to credential file writes plan_type: team tempDir := t.TempDir() store := fileauth.NewFileTokenStore() store.SetBaseDir(tempDir) refreshed.Attributes[cliproxyauth.AttributePath] = filepath.Join(tempDir, "codex-team-refreshed.json") savedPath, errSave := store.Save(ctx, refreshed) if errSave != nil { t.Fatalf("store.Save error: %v", errSave) } savedBytes, errRead := os.ReadFile(savedPath) if errRead != nil { t.Fatalf("os.ReadFile error: %v", errRead) } var fileJSON map[string]any if errUnmarshal := json.Unmarshal(savedBytes, &fileJSON); errUnmarshal != nil { t.Fatalf("json.Unmarshal error: %v", errUnmarshal) } if got := fileJSON["plan_type"]; got != "team" { t.Errorf("credential file plan_type = %v, want team", got) } } func TestCodexExecutorRefresh_EmptyAttributesSnapshotIsolation(t *testing.T) { idTokenWithoutPlan := makeTestCodexRefreshJWT("", "acc-codex-empty-attrs") proxyURL, teardown := startCodexMockOAuthServers(t, idTokenWithoutPlan) defer teardown() cfg := &config.Config{} executor := NewCodexExecutor(cfg) emptyAttrs := map[string]string{} storage := &codexauth.CodexTokenStorage{ IDToken: "old-id-token", AccessToken: "old-access-token", RefreshToken: "old-refresh-token", } auth := &cliproxyauth.Auth{ ID: "test-codex-empty-attrs", Provider: "codex", Storage: storage, ProxyURL: proxyURL, Attributes: emptyAttrs, Metadata: map[string]any{ "refresh_token": "valid-refresh-token", }, } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() refreshed, errRefresh := executor.Refresh(ctx, auth) if errRefresh != nil { t.Fatalf("executor.Refresh error = %v", errRefresh) } if got := refreshed.Attributes["plan_type"]; got != "free" { t.Errorf("refreshed.Attributes[plan_type] = %q, want free", got) } // Verify original empty map was not modified if _, exists := emptyAttrs["plan_type"]; exists { t.Errorf("original empty Attributes map was modified, plan_type = %q", emptyAttrs["plan_type"]) } if len(emptyAttrs) != 0 { t.Errorf("original empty Attributes map length = %d, want 0", len(emptyAttrs)) } } func startDualCodexAndSIWCMockOAuthServer(t *testing.T, idToken string, failSIWC *bool) (proxyURL string, outerCount *int, siwcCount *int, teardown func()) { t.Helper() var oCount, sCount int mockAuthServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/oauth/token": oCount++ _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "outer-access-refreshed", "refresh_token": "outer-refresh-refreshed", "id_token": idToken, "token_type": "Bearer", "expires_in": 720000, }) case "/api/accounts/oauth/token": sCount++ if failSIWC != nil && *failSIWC { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"invalid_grant","code":"refresh_token_reused"}`)) return } _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": "siwc-access-refreshed", "refresh_token": "siwc-refresh-refreshed", "id_token": "siwc-id-refreshed", "expires_in": 3600, "scope": codexauth.SIWCScope, }) default: w.WriteHeader(http.StatusNotFound) } })) mockProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodConnect { destConn, errDial := net.Dial("tcp", mockAuthServer.Listener.Addr().String()) if errDial != nil { http.Error(w, errDial.Error(), http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) hijacker, ok := w.(http.Hijacker) if !ok { _ = destConn.Close() return } clientConn, _, errHijack := hijacker.Hijack() if errHijack != nil { _ = destConn.Close() return } go func() { defer func() { _ = destConn.Close() }() defer func() { _ = clientConn.Close() }() _, _ = io.Copy(destConn, clientConn) }() go func() { defer func() { _ = destConn.Close() }() defer func() { _ = clientConn.Close() }() _, _ = io.Copy(clientConn, destConn) }() return } http.Error(w, "proxy only supports CONNECT", http.StatusBadRequest) })) origTransport := http.DefaultTransport http.DefaultTransport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } teardown = func() { http.DefaultTransport = origTransport mockProxy.Close() mockAuthServer.Close() } return mockProxy.URL, &oCount, &sCount, teardown } func TestCodexExecutorRefreshSelectiveAndDual(t *testing.T) { idToken := makeTestCodexRefreshJWT("plus", "acc-dual-1") failSIWC := false proxyURL, outerCount, siwcCount, teardown := startDualCodexAndSIWCMockOAuthServer(t, idToken, &failSIWC) defer teardown() executor := NewCodexExecutor(&config.Config{}) now := time.Now() // 1. Only SIWC needs refresh (outer expires in 5 days, SIWC expires in 2 minutes) *outerCount, *siwcCount = 0, 0 authSIWCOnly := &cliproxyauth.Auth{ ID: "codex-siwc-only", Provider: "codex", ProxyURL: proxyURL, Metadata: map[string]any{ "type": "codex", "access_token": "outer-keep", "refresh_token": "outer-ref-keep", "expired": now.Add(5 * 24 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-old", "refresh_token": "siwc-ref-old-1", "client_id": "oaiapp_test1", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(2 * time.Minute).Format(time.RFC3339), }, }, } beforeref1 := authSIWCOnly.Clone() ref1, err1 := executor.Refresh(context.Background(), authSIWCOnly) if err1 != nil { t.Fatalf("Refresh (SIWC only) error: %v", err1) } if *outerCount != 0 || *siwcCount != 1 { t.Fatalf("counts = outer:%d siwc:%d, want 0/1", *outerCount, *siwcCount) } if ref1.Metadata["access_token"] != "outer-keep" || ref1.Metadata["use_siwc"] != true { t.Fatalf("outer or use_siwc mutated: %#v", ref1.Metadata) } assertCodexRefreshPatch(t, beforeref1, ref1, false, true) siwc1 := ref1.Metadata["siwc"].(map[string]any) if siwc1["access_token"] != "siwc-access-refreshed" || siwc1["device_id"] != "e61bbe28-07ef-466d-8e5d-a344f94ab305" || siwc1["client_id"] != "oaiapp_test1" { t.Fatalf("siwc1 unexpected: %#v", siwc1) } // 2. Only outer needs refresh (outer expires in 2 hours, SIWC expires in 30 minutes) *outerCount, *siwcCount = 0, 0 authOuterOnly := &cliproxyauth.Auth{ ID: "codex-outer-only", Provider: "codex", ProxyURL: proxyURL, Metadata: map[string]any{ "type": "codex", "access_token": "outer-old", "refresh_token": "outer-ref-old-2", "expired": now.Add(2 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-keep", "refresh_token": "siwc-ref-keep", "client_id": "oaiapp_test2", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(30 * time.Minute).Format(time.RFC3339), }, }, } beforeref2 := authOuterOnly.Clone() ref2, err2 := executor.Refresh(context.Background(), authOuterOnly) if err2 != nil { t.Fatalf("Refresh (outer only) error: %v", err2) } if *outerCount != 1 || *siwcCount != 0 { t.Fatalf("counts = outer:%d siwc:%d, want 1/0", *outerCount, *siwcCount) } if ref2.Metadata["access_token"] != "outer-access-refreshed" || ref2.Metadata["use_siwc"] != true { t.Fatalf("outer or use_siwc unexpected: %#v", ref2.Metadata) } assertCodexRefreshPatch(t, beforeref2, ref2, true, false) siwc2 := ref2.Metadata["siwc"].(map[string]any) if siwc2["access_token"] != "siwc-keep" || siwc2["device_id"] != "e61bbe28-07ef-466d-8e5d-a344f94ab305" { t.Fatalf("siwc2 mutated: %#v", siwc2) } // 3. Both outer and SIWC need refresh simultaneously *outerCount, *siwcCount = 0, 0 authBoth := &cliproxyauth.Auth{ ID: "codex-both", Provider: "codex", ProxyURL: proxyURL, Metadata: map[string]any{ "type": "codex", "access_token": "outer-old-3", "refresh_token": "outer-ref-old-3", "expired": now.Add(1 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-old-3", "refresh_token": "siwc-ref-old-3", "client_id": "oaiapp_test3", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(1 * time.Minute).Format(time.RFC3339), }, }, } beforeref3 := authBoth.Clone() ref3, err3 := executor.Refresh(context.Background(), authBoth) if err3 != nil { t.Fatalf("Refresh (both) error: %v", err3) } if *outerCount != 1 || *siwcCount != 1 { t.Fatalf("counts = outer:%d siwc:%d, want 1/1", *outerCount, *siwcCount) } if ref3.Metadata["access_token"] != "outer-access-refreshed" || ref3.Metadata["use_siwc"] != true { t.Fatalf("ref3 outer/use_siwc unexpected: %#v", ref3.Metadata) } assertCodexRefreshPatch(t, beforeref3, ref3, true, true) siwc3 := ref3.Metadata["siwc"].(map[string]any) if siwc3["access_token"] != "siwc-access-refreshed" || siwc3["device_id"] != "e61bbe28-07ef-466d-8e5d-a344f94ab305" { t.Fatalf("siwc3 unexpected: %#v", siwc3) } // 4. Partial success when both need refresh and SIWC fails: outer rotated token must still be returned without error *outerCount, *siwcCount = 0, 0 failSIWC = true authPartial := &cliproxyauth.Auth{ ID: "codex-partial", Provider: "codex", ProxyURL: proxyURL, Metadata: map[string]any{ "type": "codex", "access_token": "outer-old-4", "refresh_token": "outer-ref-old-4", "expired": now.Add(1 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-old-4", "refresh_token": "siwc-ref-old-4", "client_id": "oaiapp_test4", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(1 * time.Minute).Format(time.RFC3339), }, }, } beforeref4 := authPartial.Clone() ref4, err4 := executor.Refresh(context.Background(), authPartial) if err4 != nil { t.Fatalf("expected partial success without error so outer rotated token is persisted, got err: %v", err4) } if ref4.Metadata["access_token"] != "outer-access-refreshed" { t.Fatalf("outer rotated token not preserved: %v", ref4.Metadata["access_token"]) } assertCodexRefreshPatch(t, beforeref4, ref4, true, false) siwc4 := ref4.Metadata["siwc"].(map[string]any) if siwc4["access_token"] != "siwc-old-4" { t.Fatalf("failed siwc should keep old token for retry, got: %#v", siwc4) } // 5. When outer is due (within 24h) and SIWC is not due by time (30m left), // a forced refresh or rejected SIWC access token must still refresh SIWC! failSIWC = false *outerCount, *siwcCount = 0, 0 authRejectedSIWC := &cliproxyauth.Auth{ ID: "codex-rejected-siwc", Provider: "codex", ProxyURL: proxyURL, RejectedAccessToken: "siwc-revoked-early", Metadata: map[string]any{ "type": "codex", "access_token": "outer-due", "refresh_token": "outer-ref-due", "expired": now.Add(2 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-revoked-early", "refresh_token": "siwc-ref-revoked", "client_id": "oaiapp_test5", "expired": now.Add(30 * time.Minute).Format(time.RFC3339), }, }, } ref5, err5 := executor.Refresh(context.Background(), authRejectedSIWC) if err5 != nil { t.Fatalf("Refresh (rejected SIWC) error: %v", err5) } if *outerCount != 1 || *siwcCount != 1 { t.Fatalf("counts for rejected SIWC = outer:%d siwc:%d, want 1/1", *outerCount, *siwcCount) } if ref5.Metadata["siwc"].(map[string]any)["access_token"] != "siwc-access-refreshed" { t.Fatalf("rejected SIWC token was not refreshed: %#v", ref5.Metadata["siwc"]) } // 6. When Auth carries a CodexTokenStorage, Refresh must attach RefreshPatch to Storage too // so direct Storage.SaveTokenToFile preserves concurrent on-disk SIWC binds. *outerCount, *siwcCount = 0, 0 tmpDir := t.TempDir() storagePath := tmpDir + "/codex-storage.json" authWithStorage := &cliproxyauth.Auth{ ID: "codex-with-storage", Provider: "codex", ProxyURL: proxyURL, Storage: &codexauth.CodexTokenStorage{ AccessToken: "outer-old-s", RefreshToken: "outer-ref-s", }, Metadata: map[string]any{ "type": "codex", "access_token": "outer-old-s", "refresh_token": "outer-ref-s", "expired": now.Add(2 * time.Hour).Format(time.RFC3339), "use_siwc": false, "siwc": map[string]any{ "access_token": "siwc-old-s", "refresh_token": "siwc-ref-s", "client_id": "oaiapp_s", "expired": now.Add(30 * time.Minute).Format(time.RFC3339), }, }, } // Simulate concurrent -siwc-bind writing a new SIWC and use_siwc=true on disk concurrentDisk := map[string]any{ "type": "codex", "access_token": "outer-old-s", "refresh_token": "outer-ref-s", "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-bound-concurrently", "refresh_token": "siwc-ref-bound-concurrently", "client_id": "oaiapp_bound", }, } if errWrite := codexauth.WriteJSONFileAtomic(storagePath, concurrentDisk); errWrite != nil { t.Fatalf("WriteJSONFileAtomic: %v", errWrite) } ref6, err6 := executor.Refresh(context.Background(), authWithStorage) if err6 != nil { t.Fatalf("Refresh (storage) error: %v", err6) } if errSave := ref6.Storage.SaveTokenToFile(storagePath); errSave != nil { t.Fatalf("Storage.SaveTokenToFile error: %v", errSave) } persistedRaw, errRead := os.ReadFile(storagePath) if errRead != nil { t.Fatalf("ReadFile: %v", errRead) } var persistedMap map[string]any if errUnmarshal := json.Unmarshal(persistedRaw, &persistedMap); errUnmarshal != nil { t.Fatalf("Unmarshal: %v", errUnmarshal) } if persistedMap["access_token"] != "outer-access-refreshed" || persistedMap["use_siwc"] != true { t.Fatalf("Storage.SaveTokenToFile did not apply RefreshPatch properly: %#v", persistedMap) } if persistedMap["siwc"].(map[string]any)["access_token"] != "siwc-bound-concurrently" { t.Fatalf("Storage.SaveTokenToFile overwrote concurrent SIWC bind: %#v", persistedMap["siwc"]) } } func TestCodexExecutorUseSIWCExecuteAndStream(t *testing.T) { var gotPath string var gotHeaders http.Header var gotBody map[string]any upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotHeaders = r.Header.Clone() raw, _ := io.ReadAll(r.Body) _ = json.Unmarshal(raw, &gotBody) w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello\"}]}],\"usage\":{\"input_tokens\":5,\"output_tokens\":2,\"total_tokens\":7}}}\n\n")) })) defer upstream.Close() auth := &cliproxyauth.Auth{ ID: "codex-siwc-req", Provider: "codex", Attributes: map[string]string{ "base_url": upstream.URL + "/v1", "websockets": "true", }, Metadata: map[string]any{ "type": "codex", "access_token": "outer-token-should-not-be-used", "account_id": "acct-should-not-be-sent", "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-direct-access-token", "refresh_token": "siwc-direct-refresh-token", "client_id": "oaiapp_direct", }, }, } if !codexUseSIWC(auth) { t.Fatal("expected codexUseSIWC(auth) == true") } if !codexWebsocketsEnabled(auth) { t.Fatal("expected codexWebsocketsEnabled(auth) == true when use_siwc is true and websockets is enabled") } // Default baseURL when base_url attribute is empty must be https://api.openai.com/v1 authDefaultURL := auth.Clone() delete(authDefaultURL.Attributes, "base_url") tok, defBase := codexCreds(authDefaultURL) if tok != "siwc-direct-access-token" || defBase != "https://api.openai.com/v1" { t.Fatalf("codexCreds = (%q, %q), want (siwc-direct-access-token, https://api.openai.com/v1)", tok, defBase) } executor := NewCodexExecutor(&config.Config{}) reqPayload := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"max_output_tokens":1024,"temperature":0.7,"prompt_cache_retention":"24h","prompt_cache_options":{"ttl":"30m"},"prompt_cache_key":"sess-123"}`) clientHeaders := http.Header{} clientHeaders.Set("User-Agent", "curl/8.7.1") _, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: reqPayload, }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatOpenAIResponse, Headers: clientHeaders, }) if errExec != nil { t.Fatalf("Execute error: %v", errExec) } if gotPath != "/v1/responses" { t.Fatalf("gotPath = %q, want /v1/responses", gotPath) } if got := gotHeaders.Get("Authorization"); got != "Bearer siwc-direct-access-token" { t.Fatalf("Authorization = %q, want Bearer siwc-direct-access-token", got) } if got := gotHeaders.Get("Chatgpt-Account-Id"); got != "" { t.Fatalf("Chatgpt-Account-Id should not be sent in SIWC mode, got %q", got) } if got := gotHeaders.Get("Originator"); got != "" { t.Fatalf("Originator should not be sent in SIWC mode, got %q", got) } if got := gotHeaders.Get("X-Codex-Routing-Hint"); got != "" { t.Fatalf("X-Codex-Routing-Hint should not be sent in SIWC mode, got %q", got) } if got := gotHeaders.Get("session_id"); got != "sess-123" { t.Fatalf("session_id = %q, want sess-123", got) } if got := gotHeaders.Get("X-Client-Request-Id"); got != "sess-123" { t.Fatalf("X-Client-Request-Id = %q, want sess-123", got) } if got := gotHeaders.Get("User-Agent"); got != codexSIWCUserAgent(nil) { t.Fatalf("User-Agent = %q, want %q (client UA must not override SIWC UA)", got, codexSIWCUserAgent(nil)) } // Verify custom signin-with-chatgpt-identifier config overrides SIWC User-Agent var customCfg config.Config customCfg.Codex.SignInWithChatGPTIdentifier = "MyCustomSIWCAgent" customExec := NewCodexExecutor(&customCfg) _, errCustomExec := customExec.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: reqPayload, }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatOpenAIResponse, Headers: clientHeaders, }) if errCustomExec != nil { t.Fatalf("Execute (custom SIWC UA) error: %v", errCustomExec) } if got := gotHeaders.Get("User-Agent"); got != codexSIWCUserAgent(&customCfg) { t.Fatalf("custom User-Agent = %q, want %q", got, codexSIWCUserAgent(&customCfg)) } for _, forbidden := range []string{"max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} { if _, exists := gotBody[forbidden]; exists { t.Fatalf("field %q should be omitted in SIWC mode, got %#v", forbidden, gotBody[forbidden]) } } if gotBody["store"] != false || gotBody["stream"] != true { t.Fatalf("store/stream = %v/%v, want false/true", gotBody["store"], gotBody["stream"]) } // Also test ExecuteStream with user payload rule to verify PayloadFinalizer is the final semantic barrier cfgWithRules := &config.Config{ Payload: config.PayloadConfig{ Override: []config.PayloadRule{ { Models: []config.PayloadModelRule{{Name: "gpt-5.4", Protocol: "codex"}}, Params: map[string]any{"service_tier": "priority"}, }, }, }, } streamExec := NewCodexExecutor(cfgWithRules) sr, errStream := streamExec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: reqPayload, }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatOpenAIResponse, }) if errStream != nil { t.Fatalf("ExecuteStream error: %v", errStream) } for range sr.Chunks { } if gotBody["service_tier"] != "priority" { t.Fatalf("expected user payload rule to set service_tier=priority after SIWC body cleanup, got %v", gotBody["service_tier"]) } for _, forbidden := range []string{"max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} { if _, exists := gotBody[forbidden]; exists { t.Fatalf("ExecuteStream field %q should be omitted in SIWC mode, got %#v", forbidden, gotBody[forbidden]) } } } func TestCodexWebsocketsExecutorUseSIWCExecuteAndStream(t *testing.T) { upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} var gotPath string var gotHeaders http.Header var gotFrame map[string]any wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotHeaders = r.Header.Clone() conn, errUpgrade := upgrader.Upgrade(w, r, nil) if errUpgrade != nil { t.Errorf("upgrade websocket: %v", errUpgrade) return } defer func() { _ = conn.Close() }() _, payload, errRead := conn.ReadMessage() if errRead != nil { t.Errorf("read upstream websocket message: %v", errRead) return } _ = json.Unmarshal(payload, &gotFrame) completed := []byte(`{"type":"response.completed","response":{"id":"resp_ws_siwc","status":"completed","model":"gpt-5.4","output":[{"type":"message","id":"msg_ws_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hello via ws"}]}],"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}`) _ = conn.WriteMessage(websocket.TextMessage, completed) })) defer wsServer.Close() auth := &cliproxyauth.Auth{ ID: "codex-siwc-ws", Provider: "codex", Attributes: map[string]string{ "base_url": wsServer.URL + "/v1", "websockets": "true", }, Metadata: map[string]any{ "type": "codex", "access_token": "outer-token-unused", "account_id": "acct-unused", "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-ws-access-token", "refresh_token": "siwc-ws-refresh-token", "client_id": "oaiapp_ws", }, }, } // Verify default wsURL is wss://api.openai.com/v1/responses when base_url is unset authDefaultURL := auth.Clone() delete(authDefaultURL.Attributes, "base_url") wsExecDefault := NewCodexWebsocketsExecutor(&config.Config{}) preparedDefault, errPrep := wsExecDefault.prepareCodexWebsocketStream(context.Background(), authDefaultURL, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) if errPrep != nil { t.Fatalf("prepareCodexWebsocketStream (default URL) error: %v", errPrep) } if preparedDefault.wsURL != "wss://api.openai.com/v1/responses" { t.Fatalf("default SIWC wsURL = %q, want wss://api.openai.com/v1/responses", preparedDefault.wsURL) } cfgWithRules := &config.Config{ Payload: config.PayloadConfig{ Override: []config.PayloadRule{ { Models: []config.PayloadModelRule{{Name: "gpt-5.4", Protocol: "codex"}}, Params: map[string]any{"service_tier": "priority"}, }, }, }, } cfgWithRules.Codex.SignInWithChatGPTIdentifier = "CustomSIWCWS" autoExec := NewCodexAutoExecutor(cfgWithRules) reqPayload := []byte(`{"model":"gpt-5.4","stream":true,"background":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"max_output_tokens":1024,"temperature":0.7,"prompt_cache_retention":"24h","prompt_cache_options":{"ttl":"30m"},"prompt_cache_key":"ws-sess-456"}`) clientHeaders := http.Header{} clientHeaders.Set("User-Agent", "curl/8.7.1") wsCtx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) _, errExec := autoExec.Execute(wsCtx, auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: reqPayload, }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatOpenAIResponse, Headers: clientHeaders, }) if errExec != nil { t.Fatalf("AutoExecutor.Execute (WebSocket SIWC) error: %v", errExec) } if gotPath != "/v1/responses" { t.Fatalf("gotPath = %q, want /v1/responses", gotPath) } if got := gotHeaders.Get("Authorization"); got != "Bearer siwc-ws-access-token" { t.Fatalf("Authorization = %q, want Bearer siwc-ws-access-token", got) } if got := gotHeaders.Get("OpenAI-Beta"); !strings.Contains(got, "responses_websockets=") { t.Fatalf("OpenAI-Beta = %q, want responses_websockets=...", got) } if got := gotHeaders.Get("User-Agent"); got != codexSIWCUserAgent(cfgWithRules) { t.Fatalf("User-Agent = %q, want %q", got, codexSIWCUserAgent(cfgWithRules)) } if got := gotHeaders.Get("session_id"); got != "ws-sess-456" { t.Fatalf("session_id = %q, want ws-sess-456", got) } if got := gotHeaders.Get("X-Client-Request-Id"); got != "ws-sess-456" { t.Fatalf("X-Client-Request-Id = %q, want ws-sess-456", got) } for _, forbiddenHeader := range []string{"ChatGPT-Account-ID", "Originator", "X-Codex-Routing-Hint", "Conversation_id"} { if got := gotHeaders.Get(forbiddenHeader); got != "" { t.Fatalf("header %q should not be sent on SIWC WebSocket handshake, got %q", forbiddenHeader, got) } } if gotFrame["type"] != "response.create" || gotFrame["store"] != false || gotFrame["service_tier"] != "priority" { t.Fatalf("unexpected SIWC WebSocket frame: %#v", gotFrame) } for _, forbidden := range []string{"stream", "background", "max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} { if _, exists := gotFrame[forbidden]; exists { t.Fatalf("field %q should be omitted in SIWC WebSocket frame: %#v", forbidden, gotFrame) } } // Stream over WebSocket with SIWC gotFrame = nil sr, errStream := autoExec.ExecuteStream(wsCtx, auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: reqPayload, }, cliproxyexecutor.Options{ SourceFormat: sdktranslator.FormatOpenAIResponse, Headers: clientHeaders, }) if errStream != nil { t.Fatalf("AutoExecutor.ExecuteStream (WebSocket SIWC) error: %v", errStream) } for range sr.Chunks { } if gotFrame["type"] != "response.create" || gotFrame["store"] != false || gotFrame["service_tier"] != "priority" { t.Fatalf("unexpected SIWC WebSocket stream frame: %#v", gotFrame) } for _, forbidden := range []string{"stream", "background", "max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} { if _, exists := gotFrame[forbidden]; exists { t.Fatalf("ExecuteStream field %q should be omitted in SIWC WebSocket frame: %#v", forbidden, gotFrame) } } // Missing SIWC token must fail closed on WebSocket Execute and ExecuteStream authMissing := auth.Clone() delete(authMissing.Metadata, "siwc") if _, errMissingExec := autoExec.Execute(wsCtx, authMissing, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: reqPayload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}); errMissingExec == nil { t.Fatal("expected WebSocket Execute to fail when use_siwc=true and siwc token is missing") } if _, errMissingStream := autoExec.ExecuteStream(wsCtx, authMissing, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: reqPayload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}); errMissingStream == nil { t.Fatal("expected WebSocket ExecuteStream to fail when use_siwc=true and siwc token is missing") } } func TestCodexExecutorUseSIWCFailsClosedWhenMissingSIWCToken(t *testing.T) { auth := &cliproxyauth.Auth{ ID: "codex-siwc-missing", Provider: "codex", Metadata: map[string]any{ "type": "codex", "access_token": "outer-fallback-token", "use_siwc": true, }, } if !codexUseSIWC(auth) { t.Fatal("expected codexUseSIWC(auth) == true when use_siwc is true") } tok, baseURL := codexCreds(auth) if tok != "" || baseURL != defaultOpenAISIWCBaseURL { t.Fatalf("codexCreds(use_siwc=true without siwc token) = (%q, %q), want (empty, %q)", tok, baseURL, defaultOpenAISIWCBaseURL) } executor := NewCodexExecutor(&config.Config{}) _, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) if errExec == nil { t.Fatal("expected Execute to fail when use_siwc=true and siwc access_token is missing") } _, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) if errStream == nil { t.Fatal("expected ExecuteStream to fail when use_siwc=true and siwc access_token is missing") } // Even if Attributes["api_key"] is present, use_siwc=true must not fall back to api_key authWithAPIKey := auth.Clone() authWithAPIKey.Attributes = map[string]string{"api_key": "sk-outer-attr"} if tok2, _ := codexCreds(authWithAPIKey); tok2 != "" { t.Fatalf("codexCreds with api_key and missing siwc token = %q, want empty", tok2) } authWithAPIKeyAndSIWC := authWithAPIKey.Clone() authWithAPIKeyAndSIWC.Metadata["siwc"] = map[string]any{"access_token": "siwc-wins-over-api-key"} if tok3, _ := codexCreds(authWithAPIKeyAndSIWC); tok3 != "siwc-wins-over-api-key" { t.Fatalf("codexCreds with api_key and siwc token = %q, want siwc-wins-over-api-key", tok3) } } func TestCodexExecutorUseSIWCImageRequestsRouteThroughResponsesSSE(t *testing.T) { var gotPath string var gotHeaders http.Header var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotHeaders = r.Header.Clone() raw, _ := io.ReadAll(r.Body) _ = json.Unmarshal(raw, &gotBody) w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img\",\"created_at\":1700000000,\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"output_format\":\"png\"}]}}\n\n")) })) defer srv.Close() executor := NewCodexExecutor(&config.Config{}) auth := &cliproxyauth.Auth{ ID: "codex-siwc-img", Provider: "codex", Attributes: map[string]string{ "base_url": srv.URL + "/v1", }, Metadata: map[string]any{ "type": "codex", "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-img-token", "client_id": "oaiapp_img", }, }, } imgOpts := cliproxyexecutor.Options{ SourceFormat: sdktranslator.FromString("openai-image"), Metadata: map[string]any{"request_path": "/v1/images/generations"}, } // Even with gpt-image-1.5 (which normally uses direct /images/generations), use_siwc=true must route via /v1/responses SSE _, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-image-1.5", Payload: []byte(`{"model":"gpt-image-1.5","prompt":"a cute cat"}`), }, imgOpts) if errExec != nil { t.Fatalf("Execute image with use_siwc error: %v", errExec) } if gotPath != "/v1/responses" { t.Fatalf("image request path = %q, want /v1/responses", gotPath) } if gotHeaders.Get("Authorization") != "Bearer siwc-img-token" { t.Fatalf("Authorization = %q, want Bearer siwc-img-token", gotHeaders.Get("Authorization")) } if _, hasInst := gotBody["instructions"]; hasInst { t.Fatalf("empty instructions should be stripped on SIWC image request: %#v", gotBody) } sr, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ Model: "gpt-image-1.5", Payload: []byte(`{"model":"gpt-image-1.5","prompt":"a cute cat","stream":true}`), }, imgOpts) if errStream != nil { t.Fatalf("ExecuteStream image with use_siwc error: %v", errStream) } for range sr.Chunks { } if gotPath != "/v1/responses" { t.Fatalf("image stream request path = %q, want /v1/responses", gotPath) } // Missing SIWC token must fail closed on image requests too authNoSIWCToken := auth.Clone() delete(authNoSIWCToken.Metadata, "siwc") if _, errMissing := executor.Execute(context.Background(), authNoSIWCToken, cliproxyexecutor.Request{ Model: "gpt-image-1.5", Payload: []byte(`{"model":"gpt-image-1.5","prompt":"a cute cat"}`), }, imgOpts); errMissing == nil { t.Fatal("expected image Execute to fail when use_siwc=true and siwc token is missing") } } func TestCodexExecutorRefreshHomeModeRefreshesSIWCWhenOnlySIWCDue(t *testing.T) { idToken := makeTestCodexRefreshJWT("plus", "acc-home-1") failSIWC := false proxyURL, outerCount, siwcCount, teardown := startDualCodexAndSIWCMockOAuthServer(t, idToken, &failSIWC) defer teardown() now := time.Now().UTC() cfg := &config.Config{} cfg.Home.Enabled = true executor := NewCodexExecutor(cfg) // Outer is valid for 5 days (not due), SIWC is due in 2m (< 5m lead) authSIWCOnly := &cliproxyauth.Auth{ ID: "codex-home-siwc-only", Provider: "codex", ProxyURL: proxyURL, Metadata: map[string]any{ "type": "codex", "access_token": "outer-home-keep", "refresh_token": "outer-home-ref", "expired": now.Add(5 * 24 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-home-old", "refresh_token": "siwc-home-ref-old", "client_id": "oaiapp_home", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(2 * time.Minute).Format(time.RFC3339), }, }, } ref, err := executor.Refresh(context.Background(), authSIWCOnly) if err != nil { t.Fatalf("Refresh (Home mode SIWC-only due) error: %v", err) } if *outerCount != 0 || *siwcCount != 1 { t.Fatalf("Home mode SIWC-only counts = outer:%d siwc:%d, want 0/1", *outerCount, *siwcCount) } if ref.Metadata["siwc"].(map[string]any)["access_token"] != "siwc-access-refreshed" { t.Fatalf("Home mode SIWC-only did not refresh siwc: %#v", ref.Metadata["siwc"]) } // Verify that a Home-refreshed outer token (without SIWC) carries CodexRefreshPatch // so saving via FileTokenStore preserves the new outer token instead of reverting to disk's old token. tmpDir := t.TempDir() homeFile := filepath.Join(tmpDir, "codex-home-outer.json") homeDiskInitial := map[string]any{ "type": "codex", "access_token": "outer-home-disk-old", "refresh_token": "outer-home-ref-disk-old", "last_refresh": now.Add(-2 * time.Hour).Format(time.RFC3339), } if err := codexauth.WriteJSONFileAtomic(homeFile, homeDiskInitial); err != nil { t.Fatalf("WriteJSONFileAtomic homeFile: %v", err) } homeAfter := map[string]any{ "type": "codex", "access_token": "outer-home-refreshed-new", "refresh_token": "outer-home-ref-refreshed-new", "last_refresh": now.Format(time.RFC3339), } homeAuth := &cliproxyauth.Auth{ ID: "codex-home-outer.json", FileName: "codex-home-outer.json", Provider: "codex", Attributes: map[string]string{cliproxyauth.AttributePath: homeFile}, Metadata: homeAfter, CodexRefreshPatch: codexauth.NewCredentialRefreshPatchWithDiskBaseline(homeDiskInitial, homeDiskInitial, homeAfter, true, false), } fStore := fileauth.NewFileTokenStore() fStore.SetBaseDir(tmpDir) if _, errSave := fStore.Save(context.Background(), homeAuth); errSave != nil { t.Fatalf("FileTokenStore.Save homeAuth error: %v", errSave) } if homeAuth.Metadata["access_token"] != "outer-home-refreshed-new" { t.Fatalf("FileTokenStore overwrote Home-refreshed outer token: %#v", homeAuth.Metadata) } } func TestCodexExecutorRefreshSerializesUnderFileLockAndSkipsAlreadyRotatedToken(t *testing.T) { idToken := makeTestCodexRefreshJWT("plus", "acc-lock-1") failSIWC := false proxyURL, outerCount, siwcCount, teardown := startDualCodexAndSIWCMockOAuthServer(t, idToken, &failSIWC) defer teardown() tmpDir := t.TempDir() authPath := filepath.Join(tmpDir, "codex-shared.json") now := time.Now().UTC() diskInitial := map[string]any{ "type": "codex", "access_token": "outer-shared-keep", "refresh_token": "outer-shared-ref", "expired": now.Add(5 * 24 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-shared-old", "refresh_token": "siwc-shared-ref-old", "client_id": "oaiapp_shared", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(2 * time.Minute).Format(time.RFC3339), }, } if err := codexauth.WriteJSONFileAtomic(authPath, diskInitial); err != nil { t.Fatalf("WriteJSONFileAtomic: %v", err) } exec1 := NewCodexExecutor(&config.Config{AuthDir: tmpDir}) exec2 := NewCodexExecutor(&config.Config{AuthDir: tmpDir}) auth1 := &cliproxyauth.Auth{ ID: "codex-shared.json", FileName: "codex-shared.json", Provider: "codex", ProxyURL: proxyURL, Attributes: map[string]string{cliproxyauth.AttributePath: authPath}, Metadata: diskInitial, } auth2 := auth1.Clone() // First process refreshes and updates diskPath inside WithFileLock ref1, err1 := exec1.Refresh(context.Background(), auth1) if err1 != nil { t.Fatalf("exec1.Refresh error: %v", err1) } // Second process holding the stale in-memory snapshot refreshes against the same file; // it must re-read disk inside WithFileLock, see the rotated SIWC token, and skip a duplicate OAuth call! ref2, err2 := exec2.Refresh(context.Background(), auth2) if err2 != nil { t.Fatalf("exec2.Refresh error: %v", err2) } if *outerCount != 0 || *siwcCount != 1 { t.Fatalf("expected exactly 1 upstream SIWC refresh across both callers, got outer:%d siwc:%d", *outerCount, *siwcCount) } if ref1.Metadata["siwc"].(map[string]any)["access_token"] != "siwc-access-refreshed" || ref2.Metadata["siwc"].(map[string]any)["access_token"] != "siwc-access-refreshed" { t.Fatalf("both callers should hold the refreshed SIWC token: ref1=%#v ref2=%#v", ref1.Metadata["siwc"], ref2.Metadata["siwc"]) } // Now test A -> B -> C: memory holds version A, disk holds version B which is ALSO due for refresh, // and Refresh rotates B -> C and persists C to both disk and memory without losing C! *outerCount, *siwcCount = 0, 0 pathABC := filepath.Join(tmpDir, "codex-abc.json") diskVersionB := map[string]any{ "type": "codex", "access_token": "outer-B", "refresh_token": "outer-ref-B", "expired": now.Add(1 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-B", "refresh_token": "siwc-ref-B", "client_id": "oaiapp_B", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(1 * time.Minute).Format(time.RFC3339), }, } if err := codexauth.WriteJSONFileAtomic(pathABC, diskVersionB); err != nil { t.Fatalf("WriteJSONFileAtomic B: %v", err) } memVersionA := &cliproxyauth.Auth{ ID: "codex-abc.json", FileName: "codex-abc.json", Provider: "codex", ProxyURL: proxyURL, Attributes: map[string]string{cliproxyauth.AttributePath: pathABC}, Metadata: map[string]any{ "type": "codex", "access_token": "outer-A", "refresh_token": "outer-ref-A", "expired": now.Add(1 * time.Hour).Format(time.RFC3339), "use_siwc": true, "siwc": map[string]any{ "access_token": "siwc-A", "refresh_token": "siwc-ref-A", "client_id": "oaiapp_A", "device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305", "expired": now.Add(1 * time.Minute).Format(time.RFC3339), }, }, } refC, errC := exec1.Refresh(context.Background(), memVersionA) if errC != nil { t.Fatalf("Refresh A->B->C error: %v", errC) } if refC.Metadata["access_token"] != "outer-access-refreshed" || refC.Metadata["siwc"].(map[string]any)["access_token"] != "siwc-access-refreshed" { t.Fatalf("A->B->C lost refreshed C tokens in memory: %#v", refC.Metadata) } rawABC, errReadABC := os.ReadFile(pathABC) if errReadABC != nil { t.Fatalf("ReadFile ABC: %v", errReadABC) } var savedABC map[string]any if errUnmarshal := json.Unmarshal(rawABC, &savedABC); errUnmarshal != nil { t.Fatalf("Unmarshal ABC: %v", errUnmarshal) } if savedABC["access_token"] != "outer-access-refreshed" || savedABC["siwc"].(map[string]any)["access_token"] != "siwc-access-refreshed" { t.Fatalf("A->B->C lost refreshed C tokens on disk: %#v", savedABC) } // Corrupted JSON on disk must fail before making any network refresh request *outerCount, *siwcCount = 0, 0 corruptPath := filepath.Join(tmpDir, "codex-corrupt.json") if err := os.WriteFile(corruptPath, []byte("{corrupt"), 0o600); err != nil { t.Fatalf("WriteFile corrupt: %v", err) } authCorrupt := memVersionA.Clone() authCorrupt.Attributes = map[string]string{cliproxyauth.AttributePath: corruptPath} if _, errCorrupt := exec1.Refresh(context.Background(), authCorrupt); errCorrupt == nil { t.Fatal("expected error when credential file on disk contains corrupt JSON") } if *outerCount != 0 || *siwcCount != 0 { t.Fatalf("corrupt disk JSON should abort before upstream OAuth call, got outer:%d siwc:%d", *outerCount, *siwcCount) } } func assertCodexRefreshPatch(t *testing.T, before, refreshed *cliproxyauth.Auth, outer, siwc bool) { t.Helper() if refreshed.CodexRefreshPatch == nil { t.Fatal("refresh did not provide an explicit credential patch") } disk := before.Clone().Metadata disk["use_siwc"] = false disk["new_property"] = "keep" if !outer { disk["access_token"] = "outer-concurrent" disk["refresh_token"] = "outer-ref-concurrent" } if !siwc { disk["siwc"].(map[string]any)["access_token"] = "siwc-concurrent" disk["siwc"].(map[string]any)["refresh_token"] = "siwc-ref-concurrent" } saved := refreshed.CodexRefreshPatch.Apply(disk) wantOuter, wantSIWC := disk["refresh_token"], disk["siwc"].(map[string]any)["refresh_token"] if outer { wantOuter = refreshed.Metadata["refresh_token"] } if siwc { wantSIWC = refreshed.Metadata["siwc"].(map[string]any)["refresh_token"] } if saved["refresh_token"] != wantOuter || saved["siwc"].(map[string]any)["refresh_token"] != wantSIWC || saved["use_siwc"] != false || saved["new_property"] != "keep" { t.Fatalf("refresh patch changed an unrefreshed partition or configuration: %#v", saved) } }