1- use std:: collections:: BTreeMap ;
1+ use std:: collections:: { BTreeMap , BTreeSet } ;
22use std:: error:: Error as StdError ;
33use std:: fs;
44use std:: io:: { IsTerminal , Write } ;
@@ -551,6 +551,8 @@ pub async fn login(base: &BaseArgs) -> Result<LoginContext> {
551551
552552#[ derive( Debug , Deserialize ) ]
553553struct AiProviderSecret {
554+ #[ serde( default ) ]
555+ id : Option < String > ,
554556 name : String ,
555557 #[ serde( default ) ]
556558 r#type : Option < String > ,
@@ -564,6 +566,18 @@ struct AiProviderSecret {
564566 created : Option < String > ,
565567}
566568
569+ #[ derive( Debug , Default , Deserialize , Serialize ) ]
570+ struct AiProviderKeyStalenessWarningState {
571+ #[ serde( default ) ]
572+ warned : BTreeSet < String > ,
573+ }
574+
575+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
576+ struct StaleAiProviderSecret {
577+ name : String ,
578+ warning_key : String ,
579+ }
580+
567581fn parse_ai_provider_secret_timestamp ( value : Option < & str > ) -> Option < DateTime < Utc > > {
568582 let value = value?. trim ( ) ;
569583 if value. is_empty ( ) {
@@ -574,12 +588,44 @@ fn parse_ai_provider_secret_timestamp(value: Option<&str>) -> Option<DateTime<Ut
574588 . map ( |parsed| parsed. with_timezone ( & Utc ) )
575589}
576590
577- fn stale_ai_provider_secret_names ( secrets : & [ AiProviderSecret ] , now : DateTime < Utc > ) -> Vec < String > {
591+ fn ai_provider_warning_state_path ( ) -> Result < PathBuf > {
592+ Ok ( crate :: config:: global_config_dir ( ) ?. join ( "ai_provider_key_warnings.json" ) )
593+ }
594+
595+ fn load_ai_provider_warning_state ( ) -> AiProviderKeyStalenessWarningState {
596+ let Ok ( path) = ai_provider_warning_state_path ( ) else {
597+ return AiProviderKeyStalenessWarningState :: default ( ) ;
598+ } ;
599+ let Ok ( contents) = fs:: read_to_string ( path) else {
600+ return AiProviderKeyStalenessWarningState :: default ( ) ;
601+ } ;
602+ serde_json:: from_str ( & contents) . unwrap_or_default ( )
603+ }
604+
605+ fn save_ai_provider_warning_state ( state : & AiProviderKeyStalenessWarningState ) -> Result < ( ) > {
606+ let path = ai_provider_warning_state_path ( ) ?;
607+ let parent = path. parent ( ) . unwrap_or_else ( || Path :: new ( "." ) ) ;
608+ fs:: create_dir_all ( parent) ?;
609+
610+ let json = serde_json:: to_string_pretty ( state) ?;
611+ let mut file = tempfile:: NamedTempFile :: new_in ( parent) ?;
612+ file. write_all ( json. as_bytes ( ) ) ?;
613+ file. write_all ( b"\n " ) ?;
614+ file. as_file ( ) . sync_all ( ) ?;
615+ file. persist ( path) ?;
616+ Ok ( ( ) )
617+ }
618+
619+ fn stale_ai_provider_secrets (
620+ org_id : & str ,
621+ secrets : & [ AiProviderSecret ] ,
622+ now : DateTime < Utc > ,
623+ ) -> Vec < StaleAiProviderSecret > {
578624 let Some ( cutoff) = now. checked_sub_months ( Months :: new ( 6 ) ) else {
579625 return Vec :: new ( ) ;
580626 } ;
581627
582- let mut stale_names = secrets
628+ let mut stale = secrets
583629 . iter ( )
584630 . filter ( |secret| {
585631 secret
@@ -588,29 +634,47 @@ fn stale_ai_provider_secret_names(secrets: &[AiProviderSecret], now: DateTime<Ut
588634 . is_some_and ( |preview| !preview. trim ( ) . is_empty ( ) )
589635 } )
590636 . filter_map ( |secret| {
591- let updated_at = parse_ai_provider_secret_timestamp (
592- secret
593- . secret_updated_at
594- . as_deref ( )
595- . or ( secret. updated_at . as_deref ( ) )
596- . or ( secret. created . as_deref ( ) ) ,
597- ) ?;
598- ( updated_at < cutoff) . then ( || {
599- secret
600- . r#type
601- . as_deref ( )
602- . filter ( |value| !value. trim ( ) . is_empty ( ) )
603- . unwrap_or ( & secret. name )
604- . to_string ( )
605- } )
637+ let updated_at_raw = secret
638+ . secret_updated_at
639+ . as_deref ( )
640+ . or ( secret. updated_at . as_deref ( ) )
641+ . or ( secret. created . as_deref ( ) ) ?;
642+ let updated_at = parse_ai_provider_secret_timestamp ( Some ( updated_at_raw) ) ?;
643+ if updated_at >= cutoff {
644+ return None ;
645+ }
646+ let identity = secret
647+ . id
648+ . as_deref ( )
649+ . or ( secret. r#type . as_deref ( ) )
650+ . unwrap_or ( & secret. name ) ;
651+ let name = secret. name . clone ( ) ;
652+ let warning_key = format ! ( "{org_id}:{identity}:{updated_at_raw}" ) ;
653+ Some ( StaleAiProviderSecret { name, warning_key } )
606654 } )
607655 . collect :: < Vec < _ > > ( ) ;
608- stale_names. sort ( ) ;
609- stale_names
656+ stale. sort_by ( |a, b| a. name . cmp ( & b. name ) ) ;
657+ stale
658+ }
659+
660+ fn unwarned_stale_ai_provider_secrets (
661+ stale : Vec < StaleAiProviderSecret > ,
662+ state : & AiProviderKeyStalenessWarningState ,
663+ ) -> Vec < StaleAiProviderSecret > {
664+ stale
665+ . into_iter ( )
666+ . filter ( |secret| !state. warned . contains ( & secret. warning_key ) )
667+ . collect ( )
610668}
611669
612670async fn maybe_warn_ai_provider_key_staleness ( base : & BaseArgs , ctx : & LoginContext ) {
613- if base. json || ui:: is_quiet ( ) || ctx. login . org_id ( ) . is_none_or ( |org_id| org_id. trim ( ) . is_empty ( ) ) {
671+ if base. json
672+ || ui:: is_quiet ( )
673+ || ctx
674+ . login
675+ . org_id ( )
676+ . is_none_or ( |org_id| org_id. trim ( ) . is_empty ( ) )
677+ {
614678 return ;
615679 }
616680 if AI_PROVIDER_KEY_STALENESS_WARNED . swap ( true , Ordering :: Relaxed ) {
@@ -645,23 +709,29 @@ async fn warn_ai_provider_key_staleness(ctx: &LoginContext) -> Result<()> {
645709 . json :: < Vec < AiProviderSecret > > ( )
646710 . await
647711 . context ( "failed to parse AI provider secrets response" ) ?;
648- let stale_names = stale_ai_provider_secret_names ( & secrets, Utc :: now ( ) ) ;
649- if stale_names. is_empty ( ) {
712+ let stale = stale_ai_provider_secrets ( & org_id, & secrets, Utc :: now ( ) ) ;
713+ if stale. is_empty ( ) {
714+ return Ok ( ( ) ) ;
715+ }
716+ let mut state = load_ai_provider_warning_state ( ) ;
717+ let to_warn = unwarned_stale_ai_provider_secrets ( stale, & state) ;
718+ if to_warn. is_empty ( ) {
650719 return Ok ( ( ) ) ;
651720 }
652721
653- let label = if stale_names. len ( ) == 1 {
654- "key has"
655- } else {
656- "keys have"
657- } ;
658- ui:: print_command_status (
659- ui:: CommandStatus :: Warning ,
660- & format ! (
661- "The following AI provider {label} not been rotated in over 6 months: {}" ,
662- stale_names. join( ", " )
663- ) ,
664- ) ;
722+ for secret in & to_warn {
723+ ui:: print_command_status (
724+ 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+ ) ,
729+ ) ;
730+ }
731+ state
732+ . warned
733+ . extend ( to_warn. into_iter ( ) . map ( |secret| secret. warning_key ) ) ;
734+ let _ = save_ai_provider_warning_state ( & state) ;
665735 Ok ( ( ) )
666736}
667737
@@ -3157,6 +3227,75 @@ mod tests {
31573227 }
31583228 }
31593229
3230+ fn dt ( value : & str ) -> DateTime < Utc > {
3231+ DateTime :: parse_from_rfc3339 ( value)
3232+ . expect ( "timestamp" )
3233+ . with_timezone ( & Utc )
3234+ }
3235+
3236+ #[ test]
3237+ fn stale_ai_provider_secrets_returns_configured_keys_older_than_six_months ( ) {
3238+ 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+ } ,
3266+ ] ;
3267+
3268+ let stale = stale_ai_provider_secrets ( "org-id" , & secrets, dt ( "2026-05-13T00:00:00Z" ) ) ;
3269+
3270+ assert_eq ! (
3271+ stale,
3272+ vec![ StaleAiProviderSecret {
3273+ name: "OPENAI_API_KEY" . to_string( ) ,
3274+ warning_key: "org-id:openai-secret:2025-11-12T00:00:00Z" . to_string( ) ,
3275+ } ]
3276+ ) ;
3277+ }
3278+
3279+ #[ test]
3280+ fn unwarned_stale_ai_provider_secrets_skips_previously_warned_key_versions ( ) {
3281+ let already_warned = StaleAiProviderSecret {
3282+ name : "openai" . to_string ( ) ,
3283+ warning_key : "org-id:secret-id:2025-11-12T00:00:00Z" . to_string ( ) ,
3284+ } ;
3285+ let newly_stale = StaleAiProviderSecret {
3286+ name : "openai" . to_string ( ) ,
3287+ warning_key : "org-id:secret-id:2026-05-13T00:00:00Z" . to_string ( ) ,
3288+ } ;
3289+ let state = AiProviderKeyStalenessWarningState {
3290+ warned : BTreeSet :: from ( [ already_warned. warning_key . clone ( ) ] ) ,
3291+ } ;
3292+
3293+ let unwarned =
3294+ unwarned_stale_ai_provider_secrets ( vec ! [ already_warned, newly_stale. clone( ) ] , & state) ;
3295+
3296+ assert_eq ! ( unwarned, vec![ newly_stale] ) ;
3297+ }
3298+
31603299 fn env_test_lock ( ) -> & ' static Mutex < ( ) > {
31613300 static LOCK : OnceLock < Mutex < ( ) > > = OnceLock :: new ( ) ;
31623301 LOCK . get_or_init ( || Mutex :: new ( ( ) ) )
0 commit comments