fix: disable revoked codex oauth accounts

This commit is contained in:
lpf
2026-03-13 11:08:28 +08:00
parent 06e3599e45
commit 80f728d8b6
4 changed files with 197 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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()