mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-06-07 05:33:09 +08:00
fix: disable revoked codex oauth accounts
This commit is contained in:
@@ -545,6 +545,16 @@ func (p *CodexProvider) postWebsocketStream(ctx context.Context, endpoint string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *CodexProvider) handleAttemptFailure(attempt authAttempt, status int, body []byte) {
|
func (p *CodexProvider) handleAttemptFailure(attempt authAttempt, status int, body []byte) {
|
||||||
|
if reason, detail, disabled := classifyCodexPermanentDisable(status, body); disabled {
|
||||||
|
if attempt.kind == "oauth" && attempt.session != nil && p.base != nil && p.base.oauth != nil {
|
||||||
|
p.base.oauth.disableSession(attempt.session, reason, detail)
|
||||||
|
recordProviderOAuthError(p.base.providerName, attempt.session, reason)
|
||||||
|
}
|
||||||
|
if attempt.kind == "api_key" && p.base != nil {
|
||||||
|
p.base.markAPIKeyFailure(reason)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
reason, retry := classifyOAuthFailure(status, body)
|
reason, retry := classifyOAuthFailure(status, body)
|
||||||
if !retry {
|
if !retry {
|
||||||
return
|
return
|
||||||
@@ -558,6 +568,21 @@ func (p *CodexProvider) handleAttemptFailure(attempt authAttempt, status int, bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classifyCodexPermanentDisable(status int, body []byte) (oauthFailureReason, string, bool) {
|
||||||
|
if status != http.StatusUnauthorized && status != http.StatusPaymentRequired {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(string(body)))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(lower, "token_revoked"), strings.Contains(lower, "invalidated oauth token"):
|
||||||
|
return oauthFailureRevoked, "oauth token revoked", true
|
||||||
|
case strings.Contains(lower, "deactivated_workspace"):
|
||||||
|
return oauthFailureDisabled, "workspace deactivated", true
|
||||||
|
default:
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *CodexProvider) doWebsocketAttempt(ctx context.Context, endpoint string, payload map[string]interface{}, attempt authAttempt, options map[string]interface{}, onDelta func(string)) ([]byte, int, string, error) {
|
func (p *CodexProvider) doWebsocketAttempt(ctx context.Context, endpoint string, payload map[string]interface{}, attempt authAttempt, options map[string]interface{}, onDelta func(string)) ([]byte, int, string, error) {
|
||||||
wsURL, err := buildCodexResponsesWebsocketURL(endpoint)
|
wsURL, err := buildCodexResponsesWebsocketURL(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/YspCoder/clawgo/pkg/config"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,6 +233,68 @@ func TestCodexHandleAttemptFailureMarksAPIKeyCooldown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodexHandleAttemptFailureDisablesRevokedOAuthSession(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
credFile := filepath.Join(dir, "codex.json")
|
||||||
|
raw, err := json.Marshal(oauthSession{
|
||||||
|
Provider: "codex",
|
||||||
|
AccessToken: "token-a",
|
||||||
|
Expire: time.Now().Add(time.Hour).Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal session: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(credFile, raw, 0o600); err != nil {
|
||||||
|
t.Fatalf("write session: %v", err)
|
||||||
|
}
|
||||||
|
manager, err := newOAuthManager(config.ProviderConfig{
|
||||||
|
Auth: "oauth",
|
||||||
|
TimeoutSec: 5,
|
||||||
|
OAuth: config.ProviderOAuthConfig{
|
||||||
|
Provider: "codex",
|
||||||
|
CredentialFile: credFile,
|
||||||
|
},
|
||||||
|
}, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new oauth manager: %v", err)
|
||||||
|
}
|
||||||
|
provider := NewCodexProvider("codex-disable-revoked", "", "", "gpt-5.4", false, "oauth", 5*time.Second, manager)
|
||||||
|
attempts, err := manager.prepareAttemptsLocked(t.Context())
|
||||||
|
if err != nil || len(attempts) != 1 {
|
||||||
|
t.Fatalf("prepare attempts = %d, err=%v", len(attempts), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.handleAttemptFailure(authAttempt{kind: "oauth", session: attempts[0].Session, token: attempts[0].Token}, http.StatusUnauthorized, []byte(`{"error":{"message":"Encountered invalidated oauth token for user, failing request","code":"token_revoked"}}`))
|
||||||
|
|
||||||
|
next, err := manager.prepareAttemptsLocked(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare attempts after disable: %v", err)
|
||||||
|
}
|
||||||
|
if len(next) != 0 {
|
||||||
|
t.Fatalf("expected disabled oauth account to be skipped, got %d attempts", len(next))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyCodexPermanentDisable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want oauthFailureReason
|
||||||
|
}{
|
||||||
|
{name: "revoked", status: http.StatusUnauthorized, body: `{"error":{"message":"Encountered invalidated oauth token for user, failing request","code":"token_revoked"}}`, want: oauthFailureRevoked},
|
||||||
|
{name: "workspace", status: http.StatusPaymentRequired, body: `{"detail":{"code":"deactivated_workspace"}}`, want: oauthFailureDisabled},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, _, ok := classifyCodexPermanentDisable(tt.status, []byte(tt.body))
|
||||||
|
if !ok || got != tt.want {
|
||||||
|
t.Fatalf("classifyCodexPermanentDisable() = (%q, %v), want (%q, true)", got, ok, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) {
|
func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) {
|
||||||
body := buildCodexWebsocketRequestBody(map[string]interface{}{
|
body := buildCodexWebsocketRequestBody(map[string]interface{}{
|
||||||
"model": "gpt-5-codex",
|
"model": "gpt-5-codex",
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ type oauthSession struct {
|
|||||||
NetworkProxy string `json:"network_proxy,omitempty"`
|
NetworkProxy string `json:"network_proxy,omitempty"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Token map[string]any `json:"token,omitempty"`
|
Token map[string]any `json:"token,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
DisableReason string `json:"disable_reason,omitempty"`
|
||||||
CooldownUntil string `json:"-"`
|
CooldownUntil string `json:"-"`
|
||||||
FailureCount int `json:"-"`
|
FailureCount int `json:"-"`
|
||||||
LastFailure string `json:"-"`
|
LastFailure string `json:"-"`
|
||||||
@@ -244,6 +246,8 @@ type OAuthAccountInfo struct {
|
|||||||
DeviceID string `json:"device_id,omitempty"`
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
ResourceURL string `json:"resource_url,omitempty"`
|
ResourceURL string `json:"resource_url,omitempty"`
|
||||||
NetworkProxy string `json:"network_proxy,omitempty"`
|
NetworkProxy string `json:"network_proxy,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
DisableReason string `json:"disable_reason,omitempty"`
|
||||||
CooldownUntil string `json:"cooldown_until,omitempty"`
|
CooldownUntil string `json:"cooldown_until,omitempty"`
|
||||||
FailureCount int `json:"failure_count,omitempty"`
|
FailureCount int `json:"failure_count,omitempty"`
|
||||||
LastFailure string `json:"last_failure,omitempty"`
|
LastFailure string `json:"last_failure,omitempty"`
|
||||||
@@ -267,6 +271,8 @@ const (
|
|||||||
oauthFailureQuota oauthFailureReason = "quota"
|
oauthFailureQuota oauthFailureReason = "quota"
|
||||||
oauthFailureRateLimit oauthFailureReason = "rate_limit"
|
oauthFailureRateLimit oauthFailureReason = "rate_limit"
|
||||||
oauthFailureForbidden oauthFailureReason = "forbidden"
|
oauthFailureForbidden oauthFailureReason = "forbidden"
|
||||||
|
oauthFailureRevoked oauthFailureReason = "token_revoked"
|
||||||
|
oauthFailureDisabled oauthFailureReason = "deactivated_workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
type oauthCallbackResult struct {
|
type oauthCallbackResult struct {
|
||||||
@@ -461,6 +467,8 @@ func buildOAuthAccountInfo(session *oauthSession) OAuthAccountInfo {
|
|||||||
DeviceID: session.DeviceID,
|
DeviceID: session.DeviceID,
|
||||||
ResourceURL: session.ResourceURL,
|
ResourceURL: session.ResourceURL,
|
||||||
NetworkProxy: maskedProxyURL(session.NetworkProxy),
|
NetworkProxy: maskedProxyURL(session.NetworkProxy),
|
||||||
|
Disabled: session.Disabled,
|
||||||
|
DisableReason: session.DisableReason,
|
||||||
CooldownUntil: session.CooldownUntil,
|
CooldownUntil: session.CooldownUntil,
|
||||||
FailureCount: session.FailureCount,
|
FailureCount: session.FailureCount,
|
||||||
LastFailure: session.LastFailure,
|
LastFailure: session.LastFailure,
|
||||||
@@ -919,6 +927,9 @@ func (m *oauthManager) prepareAttemptsLocked(ctx context.Context) ([]oauthAttemp
|
|||||||
if session == nil || strings.TrimSpace(session.AccessToken) == "" {
|
if session == nil || strings.TrimSpace(session.AccessToken) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if session.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if m.sessionOnCooldown(session) {
|
if m.sessionOnCooldown(session) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -974,6 +985,27 @@ func (m *oauthManager) markExhausted(session *oauthSession, reason oauthFailureR
|
|||||||
m.cached = rotated
|
m.cached = rotated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *oauthManager) disableSession(session *oauthSession, reason oauthFailureReason, detail string) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
session.Disabled = true
|
||||||
|
session.DisableReason = string(reason)
|
||||||
|
session.FailureCount++
|
||||||
|
session.LastFailure = firstNonEmpty(strings.TrimSpace(detail), string(reason))
|
||||||
|
session.HealthScore = 0
|
||||||
|
session.CooldownUntil = ""
|
||||||
|
if path := strings.TrimSpace(session.FilePath); path != "" {
|
||||||
|
delete(m.cooldowns, path)
|
||||||
|
}
|
||||||
|
if err := m.persistSessionLocked(session); err == nil {
|
||||||
|
m.cached = appendLoadedSession(m.cached, session)
|
||||||
|
}
|
||||||
|
recordProviderRuntimeChange(m.providerName, "oauth", firstNonEmpty(session.Email, session.AccountID, session.FilePath), "oauth_disabled_"+string(reason), "oauth credential disabled after unrecoverable upstream error")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *oauthManager) markSuccess(session *oauthSession) {
|
func (m *oauthManager) markSuccess(session *oauthSession) {
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return
|
return
|
||||||
@@ -1048,6 +1080,9 @@ func sessionHealthScore(session *oauthSession) int {
|
|||||||
if session == nil {
|
if session == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if session.Disabled {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
if session.HealthScore <= 0 {
|
if session.HealthScore <= 0 {
|
||||||
return 100
|
return 100
|
||||||
}
|
}
|
||||||
@@ -1228,6 +1263,8 @@ func (m *oauthManager) refreshSessionLocked(ctx context.Context, session *oauthS
|
|||||||
refreshed.FailureCount = session.FailureCount
|
refreshed.FailureCount = session.FailureCount
|
||||||
refreshed.LastFailure = session.LastFailure
|
refreshed.LastFailure = session.LastFailure
|
||||||
refreshed.CooldownUntil = session.CooldownUntil
|
refreshed.CooldownUntil = session.CooldownUntil
|
||||||
|
refreshed.Disabled = session.Disabled
|
||||||
|
refreshed.DisableReason = session.DisableReason
|
||||||
if err := m.persistSessionLocked(refreshed); err != nil {
|
if err := m.persistSessionLocked(refreshed); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1082,6 +1082,76 @@ func TestOAuthManagerCooldownSkipsExhaustedAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOAuthManagerDisableSessionSkipsAccount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
firstFile := filepath.Join(dir, "first.json")
|
||||||
|
secondFile := filepath.Join(dir, "second.json")
|
||||||
|
writeSession := func(path, token string) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(oauthSession{
|
||||||
|
Provider: "codex",
|
||||||
|
AccessToken: token,
|
||||||
|
Expire: time.Now().Add(time.Hour).Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal session failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||||
|
t.Fatalf("write session failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeSession(firstFile, "token-a")
|
||||||
|
writeSession(secondFile, "token-b")
|
||||||
|
|
||||||
|
manager, err := newOAuthManager(config.ProviderConfig{
|
||||||
|
Auth: "oauth",
|
||||||
|
TimeoutSec: 5,
|
||||||
|
OAuth: config.ProviderOAuthConfig{
|
||||||
|
Provider: "codex",
|
||||||
|
CredentialFile: firstFile,
|
||||||
|
CredentialFiles: []string{firstFile, secondFile},
|
||||||
|
CooldownSec: 3600,
|
||||||
|
},
|
||||||
|
}, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new oauth manager failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := manager.prepareAttemptsLocked(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare attempts failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(attempts) != 2 {
|
||||||
|
t.Fatalf("expected 2 attempts, got %d", len(attempts))
|
||||||
|
}
|
||||||
|
manager.disableSession(attempts[0].Session, oauthFailureRevoked, "oauth token revoked")
|
||||||
|
|
||||||
|
nextAttempts, err := manager.prepareAttemptsLocked(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare attempts after disable failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(nextAttempts) != 1 {
|
||||||
|
t.Fatalf("expected 1 available attempt after disable, got %d", len(nextAttempts))
|
||||||
|
}
|
||||||
|
if nextAttempts[0].Token != "token-b" {
|
||||||
|
t.Fatalf("unexpected token after disable: %s", nextAttempts[0].Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(firstFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read disabled session failed: %v", err)
|
||||||
|
}
|
||||||
|
var saved oauthSession
|
||||||
|
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||||
|
t.Fatalf("unmarshal disabled session failed: %v", err)
|
||||||
|
}
|
||||||
|
if !saved.Disabled || saved.DisableReason != string(oauthFailureRevoked) {
|
||||||
|
t.Fatalf("expected disabled session to persist, got %#v", saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOAuthManagerPrefersHealthierAccount(t *testing.T) {
|
func TestOAuthManagerPrefersHealthierAccount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user