@@ -45,15 +45,22 @@ function isOAuthRefreshFailureMessage(message: string): boolean {
4545 lower . includes ( "oauth token refresh failed" ) ||
4646 lower . includes ( "access token could not be refreshed" ) ||
4747 lower . includes ( "authentication session could not be refreshed automatically" ) ||
48- // Claude CLI subprocess emits its own 401 message when its stored OAuth
49- // token expires. OpenClaw strips ANTHROPIC_API_KEY before spawning it, so
50- // a 401 here is unambiguously an OAuth-expiry signal — recognize it so the
51- // re-auth hint reaches channel replies instead of the generic failure text.
52- ( lower . includes ( "failed to authenticate" ) && lower . includes ( "401" ) )
48+ isClaudeCliOAuth401Message ( lower )
49+ ) ;
50+ }
51+
52+ function isClaudeCliOAuth401Message ( lowercaseMessage : string ) : boolean {
53+ return (
54+ lowercaseMessage . includes ( "failed to authenticate" ) &&
55+ lowercaseMessage . includes ( "api error" ) &&
56+ / \b 4 0 1 \b / u. test ( lowercaseMessage )
5357 ) ;
5458}
5559
5660function extractOAuthRefreshFailureProvider ( message : string ) : string | null {
61+ if ( isClaudeCliOAuth401Message ( message . toLowerCase ( ) ) ) {
62+ return "claude-cli" ;
63+ }
5764 const provider = message . match ( OAUTH_REFRESH_FAILURE_PROVIDER_RE ) ?. [ 1 ] ?. trim ( ) ;
5865 return provider && provider . length > 0 ? provider : null ;
5966}
@@ -151,6 +158,14 @@ export function buildOAuthRefreshFailureLoginCommand(
151158 options ?: { profileId ?: string | null } ,
152159) : string {
153160 const sanitizedProvider = sanitizeOAuthRefreshFailureProvider ( provider ) ;
161+ if ( sanitizedProvider === "claude-cli" ) {
162+ return [
163+ "claude auth login" ,
164+ formatCliCommand (
165+ "openclaw models auth login --provider anthropic --method cli --set-default" ,
166+ ) ,
167+ ] . join ( " && " ) ;
168+ }
154169 const sanitizedProfileId = sanitizeOAuthRefreshFailureProfileId ( options ?. profileId ) ;
155170 return sanitizedProvider
156171 ? formatCliCommand (
0 commit comments