@@ -667,6 +667,12 @@ fn unwarned_stale_ai_provider_secrets(
667667 . collect ( )
668668}
669669
670+ fn ai_provider_key_staleness_warning_message ( secret_name : & str ) -> String {
671+ format ! (
672+ "We recommend disabling and rotating AI provider secrets periodically. {secret_name} has not been rotated in over 6 months."
673+ )
674+ }
675+
670676async fn maybe_warn_ai_provider_key_staleness ( base : & BaseArgs , ctx : & LoginContext ) {
671677 if base. json
672678 || ui:: is_quiet ( )
@@ -722,10 +728,7 @@ async fn warn_ai_provider_key_staleness(ctx: &LoginContext) -> Result<()> {
722728 for secret in & to_warn {
723729 ui:: print_command_status (
724730 ui:: CommandStatus :: Warning ,
725- & format ! (
726- "We recommend disabling and rotating AI provider secrets periodically. This {} has not been rotated in over 6 months." ,
727- secret. name
728- ) ,
731+ & ai_provider_key_staleness_warning_message ( & secret. name ) ,
729732 ) ;
730733 }
731734 state
@@ -3233,36 +3236,56 @@ mod tests {
32333236 . with_timezone ( & Utc )
32343237 }
32353238
3239+ fn ai_provider_secret (
3240+ id : Option < & str > ,
3241+ name : & str ,
3242+ secret_type : Option < & str > ,
3243+ preview_secret : Option < & str > ,
3244+ secret_updated_at : Option < & str > ,
3245+ updated_at : Option < & str > ,
3246+ created : Option < & str > ,
3247+ ) -> AiProviderSecret {
3248+ AiProviderSecret {
3249+ id : id. map ( str:: to_string) ,
3250+ name : name. to_string ( ) ,
3251+ r#type : secret_type. map ( str:: to_string) ,
3252+ preview_secret : preview_secret. map ( str:: to_string) ,
3253+ secret_updated_at : secret_updated_at. map ( str:: to_string) ,
3254+ updated_at : updated_at. map ( str:: to_string) ,
3255+ created : created. map ( str:: to_string) ,
3256+ }
3257+ }
3258+
32363259 #[ test]
32373260 fn stale_ai_provider_secrets_returns_configured_keys_older_than_six_months ( ) {
32383261 let secrets = vec ! [
3239- AiProviderSecret {
3240- id : Some ( "openai-secret" . to_string ( ) ) ,
3241- name : "OPENAI_API_KEY" . to_string ( ) ,
3242- r#type : Some ( "openai" . to_string ( ) ) ,
3243- preview_secret : Some ( "********" . to_string ( ) ) ,
3244- secret_updated_at : Some ( "2025-11-12T00:00:00Z" . to_string ( ) ) ,
3245- updated_at : None ,
3246- created : None ,
3247- } ,
3248- AiProviderSecret {
3249- id : Some ( "anthropic-secret" . to_string ( ) ) ,
3250- name : "ANTHROPIC_API_KEY" . to_string ( ) ,
3251- r#type : Some ( "anthropic" . to_string ( ) ) ,
3252- preview_secret : Some ( "********" . to_string ( ) ) ,
3253- secret_updated_at : Some ( "2025-11-14T00:00:00Z" . to_string ( ) ) ,
3254- updated_at : None ,
3255- created : None ,
3256- } ,
3257- AiProviderSecret {
3258- id : Some ( "gemini-secret" . to_string ( ) ) ,
3259- name : "GEMINI_API_KEY" . to_string ( ) ,
3260- r#type : Some ( "google" . to_string ( ) ) ,
3261- preview_secret : None ,
3262- secret_updated_at : Some ( "2025-01-01T00:00:00Z" . to_string ( ) ) ,
3263- updated_at : None ,
3264- created : None ,
3265- } ,
3262+ ai_provider_secret (
3263+ Some ( "openai-secret" ) ,
3264+ "OPENAI_API_KEY" ,
3265+ Some ( "openai" ) ,
3266+ Some ( "********" ) ,
3267+ Some ( "2025-11-12T00:00:00Z" ) ,
3268+ None ,
3269+ None ,
3270+ ) ,
3271+ ai_provider_secret (
3272+ Some ( "anthropic-secret" ) ,
3273+ "ANTHROPIC_API_KEY" ,
3274+ Some ( "anthropic" ) ,
3275+ Some ( "********" ) ,
3276+ Some ( "2025-11-14T00:00:00Z" ) ,
3277+ None ,
3278+ None ,
3279+ ) ,
3280+ ai_provider_secret (
3281+ Some ( "gemini-secret" ) ,
3282+ "GEMINI_API_KEY" ,
3283+ Some ( "google" ) ,
3284+ None ,
3285+ Some ( "2025-01-01T00:00:00Z" ) ,
3286+ None ,
3287+ None ,
3288+ ) ,
32663289 ] ;
32673290
32683291 let stale = stale_ai_provider_secrets ( "org-id" , & secrets, dt ( "2026-05-13T00:00:00Z" ) ) ;
@@ -3276,6 +3299,66 @@ mod tests {
32763299 ) ;
32773300 }
32783301
3302+ #[ test]
3303+ fn stale_ai_provider_secrets_uses_timestamp_fallbacks_and_ignores_invalid_dates ( ) {
3304+ let secrets = vec ! [
3305+ ai_provider_secret(
3306+ None ,
3307+ "OPENAI_API_KEY" ,
3308+ Some ( "openai" ) ,
3309+ Some ( "********" ) ,
3310+ None ,
3311+ Some ( "2025-11-12T00:00:00Z" ) ,
3312+ None ,
3313+ ) ,
3314+ ai_provider_secret(
3315+ None ,
3316+ "ANTHROPIC_API_KEY" ,
3317+ Some ( "anthropic" ) ,
3318+ Some ( "********" ) ,
3319+ None ,
3320+ Some ( "not-a-date" ) ,
3321+ Some ( "2025-01-01T00:00:00Z" ) ,
3322+ ) ,
3323+ ai_provider_secret(
3324+ None ,
3325+ "GEMINI_API_KEY" ,
3326+ Some ( "google" ) ,
3327+ Some ( "********" ) ,
3328+ None ,
3329+ None ,
3330+ None ,
3331+ ) ,
3332+ ] ;
3333+
3334+ let stale = stale_ai_provider_secrets ( "org-id" , & secrets, dt ( "2026-05-13T00:00:00Z" ) ) ;
3335+
3336+ assert_eq ! (
3337+ stale,
3338+ vec![ StaleAiProviderSecret {
3339+ name: "OPENAI_API_KEY" . to_string( ) ,
3340+ warning_key: "org-id:openai:2025-11-12T00:00:00Z" . to_string( ) ,
3341+ } ]
3342+ ) ;
3343+ }
3344+
3345+ #[ test]
3346+ fn stale_ai_provider_secrets_does_not_treat_exact_six_month_cutoff_as_stale ( ) {
3347+ let secrets = vec ! [ ai_provider_secret(
3348+ Some ( "openai-secret" ) ,
3349+ "OPENAI_API_KEY" ,
3350+ Some ( "openai" ) ,
3351+ Some ( "********" ) ,
3352+ Some ( "2025-11-13T00:00:00Z" ) ,
3353+ None ,
3354+ None ,
3355+ ) ] ;
3356+
3357+ let stale = stale_ai_provider_secrets ( "org-id" , & secrets, dt ( "2026-05-13T00:00:00Z" ) ) ;
3358+
3359+ assert ! ( stale. is_empty( ) ) ;
3360+ }
3361+
32793362 #[ test]
32803363 fn unwarned_stale_ai_provider_secrets_skips_previously_warned_key_versions ( ) {
32813364 let already_warned = StaleAiProviderSecret {
@@ -3296,6 +3379,39 @@ mod tests {
32963379 assert_eq ! ( unwarned, vec![ newly_stale] ) ;
32973380 }
32983381
3382+ #[ test]
3383+ fn ai_provider_key_staleness_warning_message_includes_key_name ( ) {
3384+ assert_eq ! (
3385+ ai_provider_key_staleness_warning_message( "OPENAI_API_KEY" ) ,
3386+ "We recommend disabling and rotating AI provider secrets periodically. OPENAI_API_KEY has not been rotated in over 6 months."
3387+ ) ;
3388+ }
3389+
3390+ #[ tokio:: test]
3391+ async fn ai_provider_warning_state_round_trips_through_global_config_dir ( ) {
3392+ let _guard = env_test_lock ( ) . lock ( ) . await ;
3393+ let previous_xdg_config_home = env:: var_os ( "XDG_CONFIG_HOME" ) ;
3394+ let previous_appdata = env:: var_os ( "APPDATA" ) ;
3395+ let config_dir = TempDir :: new ( ) . expect ( "create temp config dir" ) ;
3396+ env:: set_var ( "XDG_CONFIG_HOME" , config_dir. path ( ) ) ;
3397+ env:: set_var ( "APPDATA" , config_dir. path ( ) ) ;
3398+
3399+ let state = AiProviderKeyStalenessWarningState {
3400+ warned : BTreeSet :: from ( [
3401+ "org-id:openai-secret:2025-11-12T00:00:00Z" . to_string ( ) ,
3402+ "org-id:anthropic-secret:2025-11-10T00:00:00Z" . to_string ( ) ,
3403+ ] ) ,
3404+ } ;
3405+
3406+ save_ai_provider_warning_state ( & state) . expect ( "save warning state" ) ;
3407+ let loaded = load_ai_provider_warning_state ( ) ;
3408+
3409+ restore_env_var ( "XDG_CONFIG_HOME" , previous_xdg_config_home) ;
3410+ restore_env_var ( "APPDATA" , previous_appdata) ;
3411+
3412+ assert_eq ! ( loaded. warned, state. warned) ;
3413+ }
3414+
32993415 fn env_test_lock ( ) -> & ' static Mutex < ( ) > {
33003416 static LOCK : OnceLock < Mutex < ( ) > > = OnceLock :: new ( ) ;
33013417 LOCK . get_or_init ( || Mutex :: new ( ( ) ) )
0 commit comments