diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 07bcb6e..ab8c0ac 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -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 { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 41823b4..418a994 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -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", diff --git a/pkg/providers/oauth.go b/pkg/providers/oauth.go index da6657c..886f28b 100644 --- a/pkg/providers/oauth.go +++ b/pkg/providers/oauth.go @@ -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 } diff --git a/pkg/providers/oauth_test.go b/pkg/providers/oauth_test.go index 2081d49..addbb55 100644 --- a/pkg/providers/oauth_test.go +++ b/pkg/providers/oauth_test.go @@ -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()