mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 23:27:30 +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) {
|
||||
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)
|
||||
if !retry {
|
||||
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) {
|
||||
wsURL, err := buildCodexResponsesWebsocketURL(endpoint)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/YspCoder/clawgo/pkg/config"
|
||||
"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) {
|
||||
body := buildCodexWebsocketRequestBody(map[string]interface{}{
|
||||
"model": "gpt-5-codex",
|
||||
|
||||
@@ -129,6 +129,8 @@ type oauthSession struct {
|
||||
NetworkProxy string `json:"network_proxy,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Token map[string]any `json:"token,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
DisableReason string `json:"disable_reason,omitempty"`
|
||||
CooldownUntil string `json:"-"`
|
||||
FailureCount int `json:"-"`
|
||||
LastFailure string `json:"-"`
|
||||
@@ -244,6 +246,8 @@ type OAuthAccountInfo struct {
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
ResourceURL string `json:"resource_url,omitempty"`
|
||||
NetworkProxy string `json:"network_proxy,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
DisableReason string `json:"disable_reason,omitempty"`
|
||||
CooldownUntil string `json:"cooldown_until,omitempty"`
|
||||
FailureCount int `json:"failure_count,omitempty"`
|
||||
LastFailure string `json:"last_failure,omitempty"`
|
||||
@@ -267,6 +271,8 @@ const (
|
||||
oauthFailureQuota oauthFailureReason = "quota"
|
||||
oauthFailureRateLimit oauthFailureReason = "rate_limit"
|
||||
oauthFailureForbidden oauthFailureReason = "forbidden"
|
||||
oauthFailureRevoked oauthFailureReason = "token_revoked"
|
||||
oauthFailureDisabled oauthFailureReason = "deactivated_workspace"
|
||||
)
|
||||
|
||||
type oauthCallbackResult struct {
|
||||
@@ -461,6 +467,8 @@ func buildOAuthAccountInfo(session *oauthSession) OAuthAccountInfo {
|
||||
DeviceID: session.DeviceID,
|
||||
ResourceURL: session.ResourceURL,
|
||||
NetworkProxy: maskedProxyURL(session.NetworkProxy),
|
||||
Disabled: session.Disabled,
|
||||
DisableReason: session.DisableReason,
|
||||
CooldownUntil: session.CooldownUntil,
|
||||
FailureCount: session.FailureCount,
|
||||
LastFailure: session.LastFailure,
|
||||
@@ -919,6 +927,9 @@ func (m *oauthManager) prepareAttemptsLocked(ctx context.Context) ([]oauthAttemp
|
||||
if session == nil || strings.TrimSpace(session.AccessToken) == "" {
|
||||
continue
|
||||
}
|
||||
if session.Disabled {
|
||||
continue
|
||||
}
|
||||
if m.sessionOnCooldown(session) {
|
||||
continue
|
||||
}
|
||||
@@ -974,6 +985,27 @@ func (m *oauthManager) markExhausted(session *oauthSession, reason oauthFailureR
|
||||
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) {
|
||||
if session == nil {
|
||||
return
|
||||
@@ -1048,6 +1080,9 @@ func sessionHealthScore(session *oauthSession) int {
|
||||
if session == nil {
|
||||
return 0
|
||||
}
|
||||
if session.Disabled {
|
||||
return 0
|
||||
}
|
||||
if session.HealthScore <= 0 {
|
||||
return 100
|
||||
}
|
||||
@@ -1228,6 +1263,8 @@ func (m *oauthManager) refreshSessionLocked(ctx context.Context, session *oauthS
|
||||
refreshed.FailureCount = session.FailureCount
|
||||
refreshed.LastFailure = session.LastFailure
|
||||
refreshed.CooldownUntil = session.CooldownUntil
|
||||
refreshed.Disabled = session.Disabled
|
||||
refreshed.DisableReason = session.DisableReason
|
||||
if err := m.persistSessionLocked(refreshed); err != nil {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user