diff --git a/rust/src/providers/claude/oauth/mod.rs b/rust/src/providers/claude/oauth/mod.rs index f741800f28e..d250dbdd2da 100644 --- a/rust/src/providers/claude/oauth/mod.rs +++ b/rust/src/providers/claude/oauth/mod.rs @@ -460,8 +460,9 @@ impl ClaudeOAuthFetcher { // Check for required scope if !credentials.scopes.is_empty() && !credentials.has_scope("user:profile") { return Err(ProviderError::OAuth(format!( - "OAuth token missing 'user:profile' scope (has: {}). Run `claude setup-token` to regenerate.", - credentials.scopes.join(", ") + "OAuth token missing 'user:profile' scope (has: {}). {}", + credentials.scopes.join(", "), + Self::scope_recovery_message() ))); } @@ -510,9 +511,10 @@ impl ClaudeOAuthFetcher { } if status.as_u16() == 403 && body.contains("user:profile") { - return Err(ProviderError::OAuth( - "OAuth token does not meet scope requirement 'user:profile'. Run `claude setup-token` to regenerate.".to_string(), - )); + return Err(ProviderError::OAuth(format!( + "OAuth token does not meet scope requirement 'user:profile'. {}", + Self::scope_recovery_message() + ))); } if status.as_u16() == 429 { @@ -626,6 +628,14 @@ impl ClaudeOAuthFetcher { )) } + /// Upstream `OAuthExecutor.scopeRecoveryMessage` (#3672): recommend a + /// Claude Code sign-in token, and explain removing an OAuth override + /// before switching sources. Never mentions `claude setup-token`, which + /// produces a model-request token, not a usage-scope recovery. + fn scope_recovery_message() -> &'static str { + "Use a Claude Code sign-in token that includes 'user:profile'. To use Web/CLI instead, remove any configured OAuth token override and switch Claude Source." + } + fn refreshed_oauth_value(credentials: &ClaudeOAuthCredentials) -> serde_json::Value { serde_json::json!({ "accessToken": credentials.access_token, diff --git a/rust/src/providers/claude/oauth/tests.rs b/rust/src/providers/claude/oauth/tests.rs index ee3b7285810..6d5cfbde94c 100644 --- a/rust/src/providers/claude/oauth/tests.rs +++ b/rust/src/providers/claude/oauth/tests.rs @@ -576,3 +576,27 @@ fn backoff_kinds_have_distinct_user_messages() { assert!(cooldown.contains("retry shortly"), "{cooldown}"); assert!(cooldown.contains("claude login"), "{cooldown}"); } + +#[test] +fn missing_user_profile_scope_recommends_a_usable_credential_source() { + let recovery = super::ClaudeOAuthFetcher::scope_recovery_message(); + assert!(recovery.contains("Claude Code sign-in"), "{recovery}"); + assert!(recovery.contains("OAuth token override"), "{recovery}"); + assert!(recovery.contains("switch Claude Source"), "{recovery}"); + assert!(!recovery.contains("setup-token"), "{recovery}"); + + // Local-scope error: prefixed with the current scopes. + let credentials = test_credentials("token"); + let local = format!( + "OAuth token missing 'user:profile' scope (has: {}). {recovery}", + credentials.scopes.join(", ") + ); + assert!(local.contains("missing 'user:profile' scope"), "{local}"); + assert!(!local.contains("setup-token"), "{local}"); + + // 403 response error. + let forbidden = + format!("OAuth token does not meet scope requirement 'user:profile'. {recovery}"); + assert!(forbidden.contains("scope requirement"), "{forbidden}"); + assert!(!forbidden.contains("setup-token"), "{forbidden}"); +}